diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index e3d0a569d..8a6c868c4 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -1,4 +1,4 @@ -name: Desktop Release Guard +name: Desktop Package and Release on: pull_request: @@ -12,24 +12,76 @@ on: - 'propr-ui/**' push: tags: - - 'v*' - workflow_dispatch: + - 'desktop-v*' permissions: contents: read concurrency: - group: desktop-release-guard-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: desktop-release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} jobs: - verify: - name: Audit and package desktop app + validation-version: + name: Validate unsigned desktop package version + if: github.event_name == 'pull_request' runs-on: ubuntu-latest - timeout-minutes: 30 + outputs: + version: ${{ steps.version.outputs.version }} + release_sha: ${{ github.sha }} + steps: + - name: Checkout pull-request validation source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Resolve unsigned validation version + id: version + run: | + set -euo pipefail + version="$(node -p "require('./apps/desktop/package.json').version")" + node -e 'if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(process.argv[1])) process.exit(1)' "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + package: + name: Validate unsigned ${{ matrix.platform }}-${{ matrix.arch }} package + if: github.event_name == 'pull_request' + needs: validation-version + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runner: ubuntu-24.04 + - platform: linux + arch: arm64 + runner: ubuntu-24.04-arm + - platform: darwin + arch: x64 + runner: macos-15-intel + - platform: darwin + arch: arm64 + runner: macos-15 + - platform: win32 + arch: x64 + runner: windows-2025 + - platform: win32 + arch: arm64 + runner: windows-11-arm + env: + PROPR_DESKTOP_VERSION: ${{ needs.validation-version.outputs.version }} steps: - - name: Checkout repository + - name: Prove pull-request validation is secretless + shell: bash + run: | + node - <<'NODE' + const forbidden = Object.keys(process.env).filter(name => + /^PROPR_DESKTOP_(?:MAC_CERTIFICATE|WINDOWS_CERTIFICATE|APPLE_API_KEY|UPDATE_PRIVATE_KEY)/.test(name)); + if (forbidden.length) throw new Error(`Release secrets reached unsigned PR validation: ${forbidden.join(', ')}`); + NODE + + - name: Checkout pull-request validation source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Node.js @@ -39,33 +91,805 @@ jobs: cache: npm cache-dependency-path: package-lock.json - # Audit the committed resolution before npm lifecycle or packaging code can run. - - name: Audit production runtime dependencies (low threshold) - run: npm run audit:runtime + - name: Verify native runner architecture + shell: bash + env: + EXPECTED_PLATFORM: ${{ matrix.platform }} + EXPECTED_ARCH: ${{ matrix.arch }} + run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' - - name: Audit desktop packaging toolchain (high threshold) - run: npm run desktop:audit:packaging + - name: Audit committed dependency resolution + shell: bash + run: | + npm run audit:runtime + npm run desktop:audit:packaging - name: Install locked dependencies run: npm ci + - name: Probe Windows authority production C# before desktop suite + if: matrix.platform == 'win32' + shell: bash + run: | + PROPR_DESKTOP_PRODUCTION_RELEASE=0 npm run desktop:broker:build + npx tsx --test --test-name-pattern="native Windows exact production C# compile probe reaches ready" apps/desktop/src/windows-update-authority.test.ts + + - name: Smoke Windows authority broker before the runtime suite + if: matrix.platform == 'win32' + shell: bash + run: npx tsx --test --test-name-pattern="native Windows authority binds protected owner DACL and complete file identity" apps/desktop/src/windows-update-authority.test.ts + + - name: Run exact native Windows compiler and source barrier suite + if: matrix.platform == 'win32' + shell: bash + run: PROPR_WINDOWS_AUTHORITY_NATIVE_BUILD_TESTS=1 npx tsx --test apps/desktop/scripts/windows-authority-build.test.mjs + + - name: Run full native Windows authority suite with zero skip + if: matrix.platform == 'win32' + shell: bash + run: npx tsx --test apps/desktop/src/windows-update-authority.test.ts + + - name: Execute both inherited Windows launcher fault variables + if: matrix.platform == 'win32' + shell: bash + run: npx tsx --test --test-name-pattern="explicit inherited fault environment" apps/desktop/src/windows-update-authority.test.ts + + - name: Install native Linux package tools + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --yes cpio fakeroot rpm zip + - name: Package desktop app from clean checkout + shell: bash + run: | + test ! -e packages/shared/dist + test ! -e packages/client/dist + test ! -e apps/desktop/out + npm run desktop:package + + - name: Directly launch packaged Windows authority helper to READY + if: matrix.platform == 'win32' + shell: bash + run: npx tsx apps/desktop/scripts/probe-packaged-windows-authority.ts "$(pwd)/apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/resources/windows-authority" + + - name: Typecheck and test unsigned desktop runtime + shell: bash + run: | + npm run desktop:typecheck + npm run desktop:test + + - name: Make Linux validation packages + if: matrix.platform == 'linux' + shell: bash + run: PROPR_DESKTOP_ENABLE_DEB=1 PROPR_DESKTOP_ENABLE_RPM=1 npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Make macOS validation packages + if: matrix.platform == 'darwin' + shell: bash + run: | + npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + npm run make:dmg -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Make Windows validation installer + if: matrix.platform == 'win32' + shell: pwsh + run: npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Install and exercise machine-protected Windows authority + if: matrix.platform == 'win32' + shell: pwsh + run: | + $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') + if ($installers.Count -ne 1) { throw 'Machine-wide Windows installer is missing or ambiguous' } + & apps/desktop/scripts/test-installed-windows-authority.ps1 ` + -Installer $installers[0].FullName ` + -Architecture '${{ matrix.arch }}' + + - name: Launch packaged Linux application + if: matrix.platform == 'linux' + shell: bash + run: | + sudo chown root:root "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + sudo chmod 4755 "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + xvfb-run --auto-servernum npm run desktop:smoke + + - name: Inspect packaged application + if: matrix.platform != 'linux' + shell: bash + run: npm run desktop:smoke:inspect + + - name: Inspect native validation packages + shell: bash + run: | + if [ "${{ matrix.platform }}" = linux ]; then + dpkg-deb --info "$(find apps/desktop/out/make -type f -name '*.deb' -print -quit)" >/dev/null + rpm -qip "$(find apps/desktop/out/make -type f -name '*.rpm' -print -quit)" >/dev/null + unzip -t "$(find apps/desktop/out/make -type f -name '*.zip' -print -quit)" + elif [ "${{ matrix.platform }}" = darwin ]; then + node apps/desktop/scripts/verify-darwin-image.mjs "$(find apps/desktop/out/make -type f -name '*.dmg' -print -quit)" + unzip -t "$(find apps/desktop/out/make -type f -name '*.zip' -print -quit)" + fi + + - name: Prove private-snapshot native DMG mounting is available + if: matrix.platform == 'darwin' + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs probe-dmg-private-snapshot-isolation \ + --version "$PROPR_DESKTOP_VERSION" \ + --make-directory apps/desktop/out/make \ + --arch "${{ matrix.arch }}" + + - name: Stage architecture-verified validation artifacts with native DMG mount evidence + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs stage \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --make-directory apps/desktop/out/make \ + --output "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + + - name: Upload unsigned validation target + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-validation-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.run_id }} + path: desktop-release-${{ matrix.platform }}-${{ matrix.arch }} + if-no-files-found: error + retention-days: 14 + + finalize: + name: Finalize unsigned validation checksums + if: github.event_name == 'pull_request' + needs: [validation-version, package] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout pull-request validation source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install cross-format inspection tools + run: | + sudo apt-get update + sudo apt-get install --yes cpio p7zip-full rpm + + - name: Download all unsigned native artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + pattern: propr-desktop-validation-*-${{ github.run_id }} + path: desktop-release-fragments + + - name: Verify architecture, matrix completeness, and checksums + env: + RELEASE_VERSION: ${{ needs.validation-version.outputs.version }} + run: | + node apps/desktop/scripts/release-artifacts.mjs finalize \ + --version "$RELEASE_VERSION" \ + --input desktop-release-fragments \ + --output desktop-release-final + (cd desktop-release-final && sha256sum --check SHA256SUMS) + + preflight: + name: Protected read-only trusted release preflight + if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'desktop-v') + runs-on: ubuntu-latest + environment: + name: desktop-release-preflight + permissions: + contents: read + outputs: + version: ${{ steps.preflight.outputs.version }} + release_sha: ${{ steps.preflight.outputs.release_sha }} + tag: ${{ steps.preflight.outputs.tag }} + tag_object_sha: ${{ steps.preflight.outputs.tag_object_sha }} + steps: + - name: Checkout exact event SHA without release secrets + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Prove protected preflight has no production authority + shell: bash + run: | + node - <<'NODE' + const forbidden = Object.keys(process.env).filter(name => + /^PROPR_DESKTOP_(?:MAC_CERTIFICATE|WINDOWS_CERTIFICATE|APPLE_API_KEY|UPDATE_PRIVATE_KEY)/.test(name)); + if (forbidden.length) throw new Error(`Production release secrets reached preflight: ${forbidden.join(', ')}`); + NODE + + - name: Create short-lived read-only preflight App token + id: preflight-app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.PROPR_DESKTOP_PREFLIGHT_APP_ID }} + private-key: ${{ secrets.PROPR_DESKTOP_PREFLIGHT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: read + permission-administration: read + permission-contents: read + + - name: Verify protected-main provenance, immutable new tag, and environment policy + id: preflight + env: + GITHUB_TOKEN: ${{ steps.preflight-app-token.outputs.token }} + run: node apps/desktop/scripts/release-preflight.mjs + + release-package: + name: Sign and package ${{ matrix.platform }}-${{ matrix.arch }} production target + if: needs.preflight.result == 'success' + needs: preflight + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + environment: + name: desktop-release + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - platform: linux + arch: x64 + runner: ubuntu-24.04 + - platform: linux + arch: arm64 + runner: ubuntu-24.04-arm + - platform: darwin + arch: x64 + runner: macos-15-intel + - platform: darwin + arch: arm64 + runner: macos-15 + - platform: win32 + arch: x64 + runner: windows-2025 + - platform: win32 + arch: arm64 + runner: windows-11-arm + env: + PROPR_DESKTOP_VERSION: ${{ needs.preflight.outputs.version }} + PROPR_DESKTOP_PRODUCTION_RELEASE: '1' + PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS: '1' + UPDATE_PUBLIC_KEY: ${{ vars.PROPR_DESKTOP_UPDATE_PUBLIC_KEY }} + UPDATE_MANIFEST_URL: ${{ vars.PROPR_DESKTOP_UPDATE_MANIFEST_URL }} + UPDATE_MAC_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_MAC_SIGNING_IDENTITY }} + UPDATE_MAC_TEAM_ID: ${{ vars.PROPR_DESKTOP_MAC_TEAM_ID }} + UPDATE_WINDOWS_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY }} + UPDATE_WINDOWS_SIGNER_PINS: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNER_PINS }} + steps: + - name: Revalidate immutable tag before checkout + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + TAG_OBJECT_SHA: ${{ needs.preflight.outputs.tag_object_sha }} + run: | + set -euo pipefail + test "$(gh api "repos/${{ github.repository }}/git/ref/tags/$RELEASE_TAG" --jq .object.sha)" = "$TAG_OBJECT_SHA" + test "$(gh api "repos/${{ github.repository }}/commits/$RELEASE_TAG" --jq .sha)" = "$RELEASE_SHA" + ! gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1 + + - name: Checkout exact immutable release SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false + + - name: Verify checked out immutable SHA + shell: bash + env: + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: package-lock.json + + - name: Verify native runner architecture + shell: bash + env: + EXPECTED_PLATFORM: ${{ matrix.platform }} + EXPECTED_ARCH: ${{ matrix.arch }} + run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' + + - name: Audit committed dependency resolution + shell: bash + run: | + npm run audit:runtime + npm run desktop:audit:packaging + + - name: Install locked dependencies + run: npm ci + + - name: Probe Windows authority production C# before desktop suite + if: matrix.platform == 'win32' + shell: bash + run: | + PROPR_DESKTOP_PRODUCTION_RELEASE=0 npm run desktop:broker:build + npx tsx --test --test-name-pattern="native Windows exact production C# compile probe reaches ready" apps/desktop/src/windows-update-authority.test.ts + + - name: Smoke Windows authority broker before the runtime suite + if: matrix.platform == 'win32' + shell: bash + run: npx tsx --test --test-name-pattern="native Windows authority binds protected owner DACL and complete file identity" apps/desktop/src/windows-update-authority.test.ts + + - name: Run exact native Windows compiler and source barrier suite + if: matrix.platform == 'win32' + shell: bash + run: PROPR_WINDOWS_AUTHORITY_NATIVE_BUILD_TESTS=1 npx tsx --test apps/desktop/scripts/windows-authority-build.test.mjs + + - name: Run full native Windows authority suite with zero skip + if: matrix.platform == 'win32' + shell: bash + run: npx tsx --test apps/desktop/src/windows-update-authority.test.ts + + - name: Execute both inherited Windows launcher fault variables + if: matrix.platform == 'win32' + shell: bash + run: npx tsx --test --test-name-pattern="explicit inherited fault environment" apps/desktop/src/windows-update-authority.test.ts + + - name: Install native Linux package tools + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --yes cpio fakeroot rpm zip + + - name: Configure required macOS signing and notarization + if: matrix.platform == 'darwin' + shell: bash + env: + CERTIFICATE_P12_BASE64: ${{ secrets.PROPR_DESKTOP_MAC_CERTIFICATE_P12_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.PROPR_DESKTOP_MAC_CERTIFICATE_PASSWORD }} + APPLE_API_KEY_P8_BASE64: ${{ secrets.PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.PROPR_DESKTOP_APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.PROPR_DESKTOP_APPLE_API_ISSUER_ID }} + run: | + set -euo pipefail + for name in CERTIFICATE_P12_BASE64 CERTIFICATE_PASSWORD APPLE_API_KEY_P8_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER_ID UPDATE_MAC_SIGNING_IDENTITY UPDATE_MAC_TEAM_ID; do + test -n "${!name}" || { echo "Required production macOS field $name is missing" >&2; exit 1; } + done + certificate="$RUNNER_TEMP/propr-desktop-signing.p12" + keychain="$RUNNER_TEMP/propr-desktop-signing.keychain-db" + keychain_password="$(uuidgen)" + printf '%s' "$CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate" -k "$keychain" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" login.keychain-db + api_key="$RUNNER_TEMP/AuthKey_$APPLE_API_KEY_ID.p8" + printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$api_key" + echo "PROPR_DESKTOP_MAC_SIGNING_IDENTITY=$UPDATE_MAC_SIGNING_IDENTITY" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_APPLE_API_KEY_FILE=$api_key" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_APPLE_API_ISSUER_ID=$APPLE_API_ISSUER_ID" >> "$GITHUB_ENV" + echo "DESKTOP_PLATFORM_CODE_SIGNED=1" >> "$GITHUB_ENV" + + - name: Configure required Windows signing + if: matrix.platform == 'win32' + shell: pwsh + env: + CERTIFICATE_PFX_BASE64: ${{ secrets.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $values = @{ + CERTIFICATE_PFX_BASE64 = $env:CERTIFICATE_PFX_BASE64 + CERTIFICATE_PASSWORD = $env:CERTIFICATE_PASSWORD + UPDATE_WINDOWS_SIGNING_IDENTITY = $env:UPDATE_WINDOWS_SIGNING_IDENTITY + UPDATE_WINDOWS_SIGNER_PINS = $env:UPDATE_WINDOWS_SIGNER_PINS + } + foreach ($entry in $values.GetEnumerator()) { if (!$entry.Value) { throw "Required production Windows field $($entry.Key) is missing" } } + $pins = $env:UPDATE_WINDOWS_SIGNER_PINS -split ',' + if ($pins.Count -gt 16 -or (($pins | Sort-Object -CaseSensitive -Unique) -join ',') -cne $env:UPDATE_WINDOWS_SIGNER_PINS) { + throw 'Windows signer pin allowlist is not sorted and unique' + } + foreach ($pin in $pins) { + if ($pin -cnotmatch '^(certificate|spki)-sha256:[a-f0-9]{64}$') { throw 'Windows signer pin is not canonical' } + } + $certificate = Join-Path $env:RUNNER_TEMP 'propr-desktop-signing.pfx' + [IO.File]::WriteAllBytes($certificate, [Convert]::FromBase64String($env:CERTIFICATE_PFX_BASE64)) + $signingCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificate, + $env:CERTIFICATE_PASSWORD, + [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + ) + $codeSigningEku = @($signingCertificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } | + ForEach-Object { $_.EnhancedKeyUsages } | ForEach-Object { $_.Value }) -ccontains '1.3.6.1.5.5.7.3.3' + if ($signingCertificate.Subject -cne $env:UPDATE_WINDOWS_SIGNING_IDENTITY + -or [DateTime]::Now -lt $signingCertificate.NotBefore + -or [DateTime]::Now -gt $signingCertificate.NotAfter + -or !$codeSigningEku) { + throw 'Windows signing certificate publisher, validity, or code-signing EKU is invalid' + } + $chain = [Security.Cryptography.X509Certificates.X509Chain]::new() + $chain.ChainPolicy.RevocationMode = [Security.Cryptography.X509Certificates.X509RevocationMode]::Online + $chain.ChainPolicy.RevocationFlag = [Security.Cryptography.X509Certificates.X509RevocationFlag]::EntireChain + $chain.ChainPolicy.VerificationFlags = [Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag + $chain.ChainPolicy.UrlRetrievalTimeout = [TimeSpan]::FromSeconds(15) + if (!$chain.Build($signingCertificate)) { throw 'Windows signing certificate chain or revocation policy is invalid' } + $certificateBase64 = [Convert]::ToBase64String($signingCertificate.RawData) + $fingerprints = (node -e 'const {createHash,X509Certificate}=require("node:crypto");const certificate=new X509Certificate(Buffer.from(process.argv[1],"base64"));process.stdout.write(JSON.stringify({certificateSha256:certificate.fingerprint256.replaceAll(":","").toLowerCase(),spkiSha256:createHash("sha256").update(certificate.publicKey.export({format:"der",type:"spki"})).digest("hex")}))' $certificateBase64) | ConvertFrom-Json + $actualPins = @("certificate-sha256:$($fingerprints.certificateSha256)", "spki-sha256:$($fingerprints.spkiSha256)") + if (@($actualPins | Where-Object { $pins -ccontains $_ }).Count -eq 0) { + throw 'Windows signing certificate does not match the configured cryptographic pin policy' + } + "PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE=$certificate" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD=$env:CERTIFICATE_PASSWORD" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_WINDOWS_SIGNER_PINS=$env:UPDATE_WINDOWS_SIGNER_PINS" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256=$($fingerprints.certificateSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256=$($fingerprints.spkiSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + 'DESKTOP_PLATFORM_CODE_SIGNED=1' | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Require signed-update runtime configuration + if: matrix.platform != 'linux' + shell: bash + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + test -n "$UPDATE_PUBLIC_KEY" || { echo 'Required Ed25519 update public key is missing' >&2; exit 1; } + test -n "$UPDATE_MANIFEST_URL" || { echo 'Required update manifest URL is missing' >&2; exit 1; } + test "${DESKTOP_PLATFORM_CODE_SIGNED:-}" = 1 || { echo 'Production updates require a code-signed build' >&2; exit 1; } + if [ "$PLATFORM" = darwin ]; then + identity="$UPDATE_MAC_TEAM_ID" + else + identity="$UPDATE_WINDOWS_SIGNING_IDENTITY" + test -n "$UPDATE_WINDOWS_SIGNER_PINS" || { echo 'Required Windows signer pin allowlist is missing' >&2; exit 1; } + fi + test -n "$identity" || { echo 'Required native signing identity is missing' >&2; exit 1; } + echo "PROPR_DESKTOP_ENABLE_UPDATES=1" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_CODE_SIGNED=1" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_UPDATE_MANIFEST_URL=$UPDATE_MANIFEST_URL" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_UPDATE_PUBLIC_KEY=$UPDATE_PUBLIC_KEY" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" + + - name: Package signed production app from clean checkout + shell: bash run: | test ! -e packages/shared/dist test ! -e packages/client/dist test ! -e apps/desktop/out npm run desktop:package - - name: Typecheck desktop and renderer - run: npm run desktop:typecheck + - name: Directly launch signed packaged Windows authority helper to READY + if: matrix.platform == 'win32' + shell: bash + run: npx tsx apps/desktop/scripts/probe-packaged-windows-authority.ts "$(pwd)/apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/resources/windows-authority" + + - name: Typecheck and test production desktop runtime + shell: bash + run: | + npm run desktop:typecheck + npm run desktop:test + + - name: Make Linux production packages + if: matrix.platform == 'linux' + shell: bash + run: PROPR_DESKTOP_ENABLE_DEB=1 PROPR_DESKTOP_ENABLE_RPM=1 npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Make and notarize macOS production packages + if: matrix.platform == 'darwin' + shell: bash + run: | + npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + npm run make:dmg -w @propr/desktop -- --arch=${{ matrix.arch }} + dmg="$(find apps/desktop/out/make -type f -name '*.dmg' -print -quit)" + xcrun notarytool submit "$dmg" \ + --key "$PROPR_DESKTOP_APPLE_API_KEY_FILE" \ + --key-id "$PROPR_DESKTOP_APPLE_API_KEY_ID" \ + --issuer "$PROPR_DESKTOP_APPLE_API_ISSUER_ID" \ + --wait + xcrun stapler staple "$dmg" + xcrun stapler validate "$dmg" + node apps/desktop/scripts/verify-darwin-image.mjs "$dmg" + + - name: Make signed Windows production installer + if: matrix.platform == 'win32' + shell: pwsh + run: npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} + + - name: Install and exercise signed machine-protected Windows authority + if: matrix.platform == 'win32' + shell: pwsh + run: | + $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') + if ($installers.Count -ne 1) { throw 'Signed machine-wide Windows installer is missing or ambiguous' } + & apps/desktop/scripts/test-installed-windows-authority.ps1 ` + -Installer $installers[0].FullName ` + -Architecture '${{ matrix.arch }}' + + - name: Launch packaged Linux application + if: matrix.platform == 'linux' + shell: bash + run: | + sudo chown root:root "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + sudo chmod 4755 "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" + xvfb-run --auto-servernum npm run desktop:smoke + + - name: Inspect signed and notarized macOS application + if: matrix.platform == 'darwin' + shell: bash + run: | + npm run desktop:smoke:inspect + application="apps/desktop/out/propr-desktop-darwin-${{ matrix.arch }}/propr-desktop.app" + codesign --verify --deep --strict --verbose=2 "$application" + spctl --assess --type execute --verbose=4 "$application" + signature_details="$(codesign -dv --verbose=4 "$application" 2>&1)" + actual_authority="$(printf '%s\n' "$signature_details" | sed -n 's/^Authority=//p' | head -1)" + actual_team_id="$(printf '%s\n' "$signature_details" | sed -n 's/^TeamIdentifier=//p' | head -1)" + designated_requirement="$(codesign -d -r- "$application" 2>&1 | sed -n 's/^designated =>/designated =>/p')" + test "$actual_authority" = "$UPDATE_MAC_SIGNING_IDENTITY" + test "$actual_team_id" = "$UPDATE_MAC_TEAM_ID" + test -n "$designated_requirement" + echo "PROPR_DESKTOP_ACTUAL_SIGNER_TYPE=apple-team-id" >> "$GITHUB_ENV" + echo "PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY=$actual_team_id" >> "$GITHUB_ENV" + { + echo 'PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT<> "$GITHUB_ENV" + + - name: Inspect signed Windows application and installer payload + if: matrix.platform == 'win32' + shell: pwsh + run: | + npm run desktop:smoke:inspect + $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Setup.exe') + $machineInstallers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') + $packages = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*-full.nupkg') + $appExecutable = "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/propr-desktop.exe" + $helperExecutable = "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/resources/windows-authority/propr-windows-authority.exe" + $launcherModule = "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/resources/windows-authority/propr-windows-launcher.node" + $bootstrapModule = "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/resources/windows-authority/propr-windows-bootstrap.node" + $helperManifest = "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/resources/windows-authority/propr-windows-authority.manifest.json" + if (!(Test-Path -LiteralPath $helperExecutable -PathType Leaf) -or !(Test-Path -LiteralPath $launcherModule -PathType Leaf) -or !(Test-Path -LiteralPath $bootstrapModule -PathType Leaf) -or !(Test-Path -LiteralPath $helperManifest -PathType Leaf)) { + throw 'Packaged Windows authority helper, launcher, or bound manifest is missing' + } + node apps/desktop/scripts/inspect-packaged-windows-authority.mjs $helperExecutable $helperManifest + if ($installers.Count -ne 1 -or $machineInstallers.Count -ne 1 -or $packages.Count -ne 1) { throw 'Windows release artifacts are missing or ambiguous' } + $installer = $installers[0] + $machineInstaller = $machineInstallers[0] + $package = $packages[0] + node apps/desktop/scripts/release-architecture.mjs inspect ` + --path $package.FullName ` + --kind nupkg ` + --platform win32 ` + --arch '${{ matrix.arch }}' + $zip = Join-Path $env:RUNNER_TEMP 'propr-update-package.zip' + $extracted = Join-Path $env:RUNNER_TEMP 'propr-update-package' + Copy-Item -LiteralPath $package.FullName -Destination $zip + Expand-Archive -LiteralPath $zip -DestinationPath $extracted + $packageExecutable = Get-Item -LiteralPath (Join-Path $extracted 'lib/net45/propr-desktop.exe') + if (!$packageExecutable -or $packageExecutable.PSIsContainer) { throw 'Windows update package canonical application is missing' } + $packageHelper = Get-Item -LiteralPath (Join-Path $extracted 'lib/net45/resources/windows-authority/propr-windows-authority.exe') + $packageLauncher = Get-Item -LiteralPath (Join-Path $extracted 'lib/net45/resources/windows-authority/propr-windows-launcher.node') + $packageBootstrap = Get-Item -LiteralPath (Join-Path $extracted 'lib/net45/resources/windows-authority/propr-windows-bootstrap.node') + $packageHelperManifest = Get-Item -LiteralPath (Join-Path $extracted 'lib/net45/resources/windows-authority/propr-windows-authority.manifest.json') + if (!$packageHelper -or $packageHelper.PSIsContainer -or !$packageLauncher -or $packageLauncher.PSIsContainer -or !$packageBootstrap -or $packageBootstrap.PSIsContainer -or !$packageHelperManifest -or $packageHelperManifest.PSIsContainer) { + throw 'Windows update package authority helper, launcher, or bound manifest is missing' + } + node apps/desktop/scripts/inspect-packaged-windows-authority.mjs $packageHelper.FullName $packageHelperManifest.FullName + function Get-ValidatedSignerEvidence([string]$Path) { + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($signature.Status -ne 'Valid' -or !$signature.SignerCertificate -or !$signature.TimeStamperCertificate) { + throw "Windows Authenticode chain or timestamp status is invalid for $Path" + } + $certificateBase64 = [Convert]::ToBase64String($signature.SignerCertificate.RawData) + $fingerprints = (node -e 'const {createHash,X509Certificate}=require("node:crypto");const certificate=new X509Certificate(Buffer.from(process.argv[1],"base64"));process.stdout.write(JSON.stringify({certificateSha256:certificate.fingerprint256.replaceAll(":","").toLowerCase(),spkiSha256:createHash("sha256").update(certificate.publicKey.export({format:"der",type:"spki"})).digest("hex")}))' $certificateBase64) | ConvertFrom-Json + [PSCustomObject]@{ + Subject = $signature.SignerCertificate.Subject + CertificateSha256 = $fingerprints.certificateSha256 + SpkiSha256 = $fingerprints.spkiSha256 + } + } + $evidence = @( + Get-ValidatedSignerEvidence $installer.FullName + Get-ValidatedSignerEvidence $machineInstaller.FullName + Get-ValidatedSignerEvidence $appExecutable + Get-ValidatedSignerEvidence $packageExecutable.FullName + Get-ValidatedSignerEvidence $helperExecutable + Get-ValidatedSignerEvidence $launcherModule + Get-ValidatedSignerEvidence $bootstrapModule + Get-ValidatedSignerEvidence $packageHelper.FullName + Get-ValidatedSignerEvidence $packageLauncher.FullName + Get-ValidatedSignerEvidence $packageBootstrap.FullName + ) + foreach ($signer in $evidence) { + if ($signer.Subject -cne $env:UPDATE_WINDOWS_SIGNING_IDENTITY) { throw 'Windows Authenticode signer does not match the configured exact subject' } + } + $distinctSigners = @($evidence | ForEach-Object { $_ | ConvertTo-Json -Compress } | Sort-Object -Unique) + if ($distinctSigners.Count -ne 1) { throw 'Windows artifacts have mixed Authenticode signers' } + $actualPins = @( + "certificate-sha256:$($evidence[0].CertificateSha256)" + "spki-sha256:$($evidence[0].SpkiSha256)" + ) + $allowedPins = @($env:UPDATE_WINDOWS_SIGNER_PINS -split ',') + if (@($actualPins | Where-Object { $allowedPins -ccontains $_ }).Count -eq 0) { + throw 'Windows Authenticode signer does not match the configured build pin' + } + "PROPR_DESKTOP_ACTUAL_SIGNER_TYPE=authenticode-subject" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY=$($evidence[0].Subject)" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256=$($evidence[0].CertificateSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256=$($evidence[0].SpkiSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Inspect native Linux production packages + if: matrix.platform == 'linux' + shell: bash + run: | + dpkg-deb --info "$(find apps/desktop/out/make -type f -name '*.deb' -print -quit)" >/dev/null + rpm -qip "$(find apps/desktop/out/make -type f -name '*.rpm' -print -quit)" >/dev/null + unzip -t "$(find apps/desktop/out/make -type f -name '*.zip' -print -quit)" + + - name: Prove private-snapshot native DMG mounting is available + if: matrix.platform == 'darwin' + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs probe-dmg-private-snapshot-isolation \ + --version "$PROPR_DESKTOP_VERSION" \ + --make-directory apps/desktop/out/make \ + --arch "${{ matrix.arch }}" + + - name: Stage architecture and signer verified production artifacts with native DMG mount evidence + shell: bash + run: | + node apps/desktop/scripts/release-artifacts.mjs stage \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --make-directory apps/desktop/out/make \ + --output "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + + - name: Upload trusted production target + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-production-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.run_id }} + path: desktop-release-${{ matrix.platform }}-${{ matrix.arch }} + if-no-files-found: error + retention-days: 14 - - name: Test desktop runtime - run: npm run desktop:test + release-finalize: + name: Revalidate production architectures and finalize checksums + if: needs.preflight.result == 'success' + needs: [preflight, release-package] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout exact immutable release SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false - - name: Configure Chromium sandbox helper + - name: Install cross-format inspection tools run: | - sudo chown root:root apps/desktop/out/propr-desktop-linux-x64/chrome-sandbox - sudo chmod 4755 apps/desktop/out/propr-desktop-linux-x64/chrome-sandbox + sudo apt-get update + sudo apt-get install --yes cpio p7zip-full rpm - - name: Launch packaged desktop app with sandboxing - run: xvfb-run --auto-servernum npm run desktop:smoke + - name: Download all trusted native artifacts + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + pattern: propr-desktop-production-*-${{ github.run_id }} + path: desktop-release-fragments + + - name: Verify architecture, signer evidence, matrix completeness, and checksums + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + node apps/desktop/scripts/release-artifacts.mjs finalize \ + --version "$RELEASE_VERSION" \ + --input desktop-release-fragments \ + --output desktop-release-validated + (cd desktop-release-validated && sha256sum --check SHA256SUMS) + + - name: Upload complete validated release set + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-validated-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-validated + if-no-files-found: error + retention-days: 30 + + sign: + name: Sign trusted update metadata + if: needs.preflight.result == 'success' + needs: [preflight, release-finalize] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: desktop-release + permissions: + contents: read + steps: + - name: Revalidate immutable tag before secret use + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + TAG_OBJECT_SHA: ${{ needs.preflight.outputs.tag_object_sha }} + run: | + set -euo pipefail + test "$(gh api "repos/${{ github.repository }}/git/ref/tags/$RELEASE_TAG" --jq .object.sha)" = "$TAG_OBJECT_SHA" + test "$(gh api "repos/${{ github.repository }}/commits/$RELEASE_TAG" --jq .sha)" = "$RELEASE_SHA" + ! gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1 + + - name: Checkout exact immutable release SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false + + - name: Download validated release set + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: propr-desktop-validated-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-validated + + - name: Sign cryptographically bound update metadata + env: + PROPR_DESKTOP_UPDATE_PRIVATE_KEY: ${{ secrets.PROPR_DESKTOP_UPDATE_PRIVATE_KEY }} + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: ${{ vars.PROPR_DESKTOP_UPDATE_PUBLIC_KEY }} + PROPR_DESKTOP_UPDATE_MANIFEST_URL: ${{ vars.PROPR_DESKTOP_UPDATE_MANIFEST_URL }} + PROPR_DESKTOP_MAC_TEAM_ID: ${{ vars.PROPR_DESKTOP_MAC_TEAM_ID }} + PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY }} + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNER_PINS }} + PROPR_DESKTOP_DARWIN_X64_FEED_URL: ${{ vars.PROPR_DESKTOP_DARWIN_X64_FEED_URL }} + PROPR_DESKTOP_DARWIN_ARM64_FEED_URL: ${{ vars.PROPR_DESKTOP_DARWIN_ARM64_FEED_URL }} + PROPR_DESKTOP_WINDOWS_X64_FEED_URL: ${{ vars.PROPR_DESKTOP_WINDOWS_X64_FEED_URL }} + PROPR_DESKTOP_WINDOWS_ARM64_FEED_URL: ${{ vars.PROPR_DESKTOP_WINDOWS_ARM64_FEED_URL }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + node apps/desktop/scripts/release-artifacts.mjs sign \ + --version "$RELEASE_VERSION" \ + --input desktop-release-validated \ + --output desktop-release-signed + test -s desktop-release-signed/desktop-release.json.sig + (cd desktop-release-signed && sha256sum --check SHA256SUMS) + + - name: Upload signed release set + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: propr-desktop-signed-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-signed + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish new immutable desktop release + if: needs.preflight.result == 'success' + needs: [preflight, sign] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Checkout exact approved publication helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight.outputs.release_sha }} + persist-credentials: false + + - name: Download signed release set + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: propr-desktop-signed-release-${{ needs.preflight.outputs.version }}-${{ github.run_id }} + path: desktop-release-final + + - name: Publish only the preflight-approved tag and signed bytes + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + TAG_OBJECT_SHA: ${{ needs.preflight.outputs.tag_object_sha }} + RELEASE_DIRECTORY: desktop-release-final + run: | + set -euo pipefail + test -s desktop-release-final/desktop-release.json.sig + node apps/desktop/scripts/release-publish.mjs diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6ba6d7e1e..f027c11d9 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -18,6 +18,8 @@ npm run desktop:audit # On Linux hosts with the corresponding native packaging tools installed: npm run make:deb -w @propr/desktop npm run make:rpm -w @propr/desktop +# macOS only, after packaging the selected architecture: +npm run make:dmg -w @propr/desktop -- --arch=arm64 ``` Desktop development, typecheck, package, and make commands build required renderer workspace dependencies through @@ -27,10 +29,24 @@ generated workspace `dist` directories. Development renderer URLs are accepted only when Electron Forge supplies an HTTP loopback URL. Packaged builds load the generated renderer from the application ASAR through an app-owned protocol. -The packaged-binary smoke test verifies the hardened fuse states, launches the Linux artifact at 1280x820 without a -sandbox-disabling flag, rejects main-process uncaught exceptions, and requires proof that `window.proprDesktop` is -exposed. It also checks the real renderer bounds for the title-bar logo and connection-card controls before accepting -renderer-ready and a clean exit. +The packaged-binary smoke test verifies the hardened fuse states, launches artifacts where the host permits (at +1280x820 on Linux) without a sandbox-disabling flag, rejects main-process uncaught exceptions, and requires proof that +`window.proprDesktop` is exposed. It also checks the real renderer bounds for the title-bar logo and connection-card +controls before accepting renderer-ready and a clean exit. `desktop:smoke:inspect` performs executable and fuse +inspection without launching a window. Release CI launches both Linux architectures under Xvfb, inspects macOS and +Windows packages on their native runners, validates DMG/ZIP/DEB/RPM/NuGet containers, and validates configured OS +signatures. + +On native Windows builds, `desktop:broker:build` obtains the Windows directory from a fixed-size native +`GetSystemWindowsDirectoryW` probe after authenticating the canonical system PowerShell image, then compiles the +committed authority-broker C# source with the exact leased .NET Framework compiler and reference files below that +directory. The build emits a managed AnyCPU PE, a per-architecture Node-API lease/launcher, and a deterministic strict +manifest binding both binaries, the source and compiler-input digests, format, protocol, signer pins, and trust mode. +Forge packages exactly those three files under `resources/windows-authority`; Windows signing covers both PE images +before the post-package hook refreshes their final-byte hashes, and NUPKG/release checksum validation requires the same +exact set. The packaged application uses the native boundary to hold the helper file against write/delete/rename, +create it with only three inherited anonymous-pipe handles, assign a parent-owned kill-on-close job, and prove the +loaded process image before accepting READY. End-user machines never compile source or invoke a shell. `desktop:audit` deliberately applies separate policies to the two dependency surfaces: low-or-higher advisories fail the production-runtime audit, while high and critical advisories fail the desktop development/build-tool audit. Release @@ -50,3 +66,124 @@ fallback. Profiles remain usable because they contain only a display label and v `propr://connect` and `propr://open` are the only accepted deep-link actions. A single-instance lock routes later activations to the existing window. Local lifecycle methods intentionally return `not-implemented`; this scaffold does not download, install, start, or execute ProPR runtime components. + +## Desktop distributables and releases + +Desktop releases have their own `desktop-v..` tags. They do not use or require the monorepo's +`v` tag. `PROPR_DESKTOP_VERSION` propagates the tag version into the packaged application, renderer, native +metadata, Linux packages, Squirrel package, artifact names, and release manifest without changing the monorepo +package versions. + +The native GitHub Actions matrix produces these assets for both x64 and arm64: + +| Platform | Native runner | Direct-distribution artifacts | +| --- | --- | --- | +| Linux | `ubuntu-24.04`, `ubuntu-24.04-arm` | DEB, RPM, ZIP | +| macOS | `macos-15-intel`, `macos-15` | DMG, ZIP | +| Windows | `windows-2025`, `windows-11-arm` | Squirrel Setup.exe, full NuGet update package, RELEASES metadata | + +Every matrix job stages names in the form `ProPR-Desktop----`. The final job rejects +missing targets or changed fragment checksums, emits `SHA256SUMS` and `desktop-release.json`, and attaches the complete +set to the matching GitHub release. Production publication is triggered only by a new, non-forced +`desktop-v..` tag push; there is no manual dispatch path. A secretless preflight must succeed before +any job can request the protected release environment or receive release secrets. Normal local packages are unsigned +and have updates disabled: + +```sh +npm ci +npm run desktop:typecheck +npm run desktop:test +npm run desktop:package +xvfb-run --auto-servernum npm run desktop:smoke # Linux + +# Full unsigned Linux release artifacts (requires dpkg-deb and rpmbuild/rpm): +PROPR_DESKTOP_VERSION=1.2.3 \ +PROPR_DESKTOP_ENABLE_DEB=1 \ +PROPR_DESKTOP_ENABLE_RPM=1 \ +npm run make -w @propr/desktop -- --arch="$(node -p process.arch)" +``` + +### CI preflight, signing, and notarization configuration + +Repository-ruleset inspection uses a dedicated GitHub App installed only on this repository. Configure the App with +exactly repository **Administration: read** and **Contents: read** (GitHub adds Metadata: read implicitly), with no +write permission and no Actions, Deployments, Environments, Releases, or other repository permission. Store its +private key only in a separate approval-protected `desktop-release-preflight` environment: + +- Variable `PROPR_DESKTOP_PREFLIGHT_APP_ID`: the least-privilege preflight App ID. +- Secret `PROPR_DESKTOP_PREFLIGHT_APP_PRIVATE_KEY`: that App's private key. + +Configure `desktop-release-preflight` with at least one required reviewer, custom deployment policies enabled, +protected-branch policies disabled, and exactly one deployment policy: the tag pattern `desktop-v*`. The workflow +uses a SHA-pinned token action to mint a short-lived installation token explicitly requesting only Administration read +and Contents read; workflow regression tests pin those exact inputs and reject any write or Actions permission. The +App installation itself must have the same exact least-privilege permission set. Preflight fails closed when the +ruleset API does not return `bypass_actors`. Pull requests do not schedule this job, and a nonmatching or unreviewed tag +cannot enter the environment or obtain the App credential. The preflight environment must contain no signing, +notarization, update-signing, release-publication, or production deployment secret. + +Signing material is read only from the distinct approval-protected `desktop-release` GitHub environment and written +to runner-temporary files/keychains. Every value below is mandatory for a production `desktop-v*` tag; unsigned and +partially signed production releases fail before publication. Pull-request package validation and the preflight +environment receive none of these secrets and explicitly check that release-secret environment variables are absent. + +GitHub Actions secrets: + +- `PROPR_DESKTOP_MAC_CERTIFICATE_P12_BASE64`: base64 of the Developer ID Application `.p12`. +- `PROPR_DESKTOP_MAC_CERTIFICATE_PASSWORD`: password for that `.p12`. +- `PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64`: base64 of the App Store Connect API `.p8` key. +- `PROPR_DESKTOP_APPLE_API_KEY_ID`: App Store Connect API key ID. +- `PROPR_DESKTOP_APPLE_API_ISSUER_ID`: App Store Connect issuer UUID. +- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64`: base64 of the Authenticode `.pfx`. +- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD`: password for that `.pfx`. +- `PROPR_DESKTOP_UPDATE_PRIVATE_KEY`: base64 Ed25519 PKCS#8 DER key used only to sign update-channel metadata. + +GitHub Actions variables (public configuration, not secrets): + +- `PROPR_DESKTOP_MAC_SIGNING_IDENTITY`: exact Developer ID Application identity. +- `PROPR_DESKTOP_MAC_TEAM_ID`: exact Team ID embedded in signed macOS update builds and verified from produced apps. +- `PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY`: exact Authenticode certificate subject expected by installed builds. +- `PROPR_DESKTOP_WINDOWS_SIGNER_PINS`: sorted, unique comma-separated allowlist of one or more + `certificate-sha256:<64 lowercase hex>` or `spki-sha256:<64 lowercase hex>` fingerprints. Production Windows + packaging fails closed when this public operator pin is absent, malformed, or does not match the signing key. +- `PROPR_DESKTOP_UPDATE_PUBLIC_KEY`: base64 Ed25519 SPKI DER public key matching the update private key. +- `PROPR_DESKTOP_UPDATE_MANIFEST_URL`: stable HTTPS URL from which clients fetch `desktop-release.json`; the detached + signature must be published beside it as `desktop-release.json.sig`. +- `PROPR_DESKTOP_DARWIN_X64_FEED_URL`, `PROPR_DESKTOP_DARWIN_ARM64_FEED_URL`: Squirrel.Mac JSON feed URLs. +- `PROPR_DESKTOP_WINDOWS_X64_FEED_URL`, `PROPR_DESKTOP_WINDOWS_ARM64_FEED_URL`: Squirrel.Windows feed directories. + +Generate the independent update-channel keys once and store only the public output as a repository variable: + +```sh +openssl genpkey -algorithm ED25519 -outform DER -out desktop-update-private.der +openssl pkey -inform DER -in desktop-update-private.der -pubout -outform DER -out desktop-update-public.der +base64 < desktop-update-private.der # secret: PROPR_DESKTOP_UPDATE_PRIVATE_KEY +base64 < desktop-update-public.der # variable: PROPR_DESKTOP_UPDATE_PUBLIC_KEY +``` + +Do not commit either key file. The private key is available only to the approval-protected `desktop-release` +environment. Configure that environment with at least one required reviewer, custom deployment policies enabled, +protected-branch policies disabled, and exactly one deployment policy: the tag pattern `desktop-v*`. The repository's +default branch must be protected `main`. It must also have an active tag-targeting ruleset whose sole include is +`refs/tags/desktop-v*`, whose exclude and bypass-actor lists are empty, and whose rules block both tag updates and tag +deletions. + +For each new, non-forced `desktop-v..` tag push, the read-only preflight verifies both protected +environments and the repository prerequisites through the GitHub API, proves the exact tag commit is reachable from +`main`, rejects an existing release, and rechecks the tag and immutability ruleset for changes. The active tag ruleset +must match exactly `refs/tags/desktop-v*`, have no exclusions or bypass actors, and block update and deletion. Pull- +request finalization produces unsigned validation metadata; trusted signing jobs depend on preflight, check out its +immutable SHA, revalidate the tag before publication, and fail closed if any signing, notarization, or signed-update +field is missing. A release operator must publish the exact signed manifest/signature, generated native feeds, and +bound packages to their configured HTTPS URLs. The manifest URL must not contain a query, so its companion is always +the documented pathname plus `.sig`. + +Linux never checks for native updates. macOS and Windows operate as check-only channels: they verify the Ed25519 +manifest, exact target/version/feed bytes, package URL/size/SHA-256, and the actual Team ID/designated requirement or +Authenticode certificate subject plus certificate/SPKI SHA-256 fingerprints extracted from the downloaded package. +Windows requires the identical valid, timestamped signer on the installer, packaged application, and the exact +`lib/net45/propr-desktop.exe` from the validated NUPKG; the runtime also requires its signed fingerprint evidence to +match the allowlist embedded in the installed build. Electron's `autoUpdater` is not initialized, +because it would re-fetch mutable URLs instead of installing the already verified bytes. Unsigned developer packages +remain update-disabled. The internal apply API exposes only a one-shot held-byte capability, never a verified mutable +pathname; without a platform adapter that can consume that held/locked capability, automatic apply fails closed. diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index a2d291851..1ffc8208d 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -5,16 +5,101 @@ import { MakerSquirrel } from '@electron-forge/maker-squirrel'; import { MakerZIP } from '@electron-forge/maker-zip'; import { VitePlugin } from '@electron-forge/plugin-vite'; import { flipFuses, FuseV1Options, FuseVersion } from '@electron/fuses'; +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + readCompleteEnvironmentGroup, + requireProductionReleaseConfiguration, + resolveDesktopVersion, + resolveTrustedUpdateBuildConfig, +} from './src/release-config'; +import { DESKTOP_EXECUTABLE_NAME, SQUIRREL_PACKAGE_NAME } from './src/squirrel-events'; + +const desktopPackage = JSON.parse( + readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'), +) as { version: string }; +const releaseVersion = resolveDesktopVersion(desktopPackage.version); +const updateConfig = resolveTrustedUpdateBuildConfig(); +const macSigning = readCompleteEnvironmentGroup( + process.env, + ['PROPR_DESKTOP_MAC_SIGNING_IDENTITY'], + 'macOS signing', +); +const macNotarization = readCompleteEnvironmentGroup( + process.env, + [ + 'PROPR_DESKTOP_APPLE_API_KEY_FILE', + 'PROPR_DESKTOP_APPLE_API_KEY_ID', + 'PROPR_DESKTOP_APPLE_API_ISSUER_ID', + ], + 'macOS notarization', +); +const windowsSigning = readCompleteEnvironmentGroup( + process.env, + ['PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE', 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD'], + 'Windows signing', +); + +if (macNotarization && !macSigning) { + throw new Error('macOS notarization requires macOS signing configuration'); +} +if (updateConfig.enabled) { + if (process.platform === 'darwin' && !macSigning) { + throw new Error('The macOS signed-update build must have a macOS signing identity'); + } + if (process.platform === 'win32' && !windowsSigning) { + throw new Error('The Windows signed-update build must have a Windows signing certificate'); + } +} +if (process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1') { + requireProductionReleaseConfiguration({ + platform: process.platform, + updateConfig, + macSigning, + macNotarization, + windowsSigning, + }); +} + +const windowsSign = windowsSigning ? { + certificateFile: windowsSigning.PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE, + certificatePassword: windowsSigning.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD, + description: 'ProPR Desktop', +} : undefined; const config: ForgeConfig = { packagerConfig: { asar: true, - name: 'propr-desktop', - executableName: 'propr-desktop', + appBundleId: 'dev.propr.desktop', + appCategoryType: 'public.app-category.developer-tools', + appVersion: releaseVersion, + buildVersion: releaseVersion, + name: DESKTOP_EXECUTABLE_NAME, + executableName: DESKTOP_EXECUTABLE_NAME, + ...(process.platform === 'win32' ? { extraResource: [resolve('build', 'windows-authority')] } : {}), + protocols: [{ name: 'ProPR Desktop', schemes: ['propr'] }], + ...(macSigning ? { + osxSign: { + continueOnError: false, + identity: macSigning.PROPR_DESKTOP_MAC_SIGNING_IDENTITY, + }, + } : {}), + ...(macNotarization ? { + osxNotarize: { + appleApiKey: macNotarization.PROPR_DESKTOP_APPLE_API_KEY_FILE, + appleApiKeyId: macNotarization.PROPR_DESKTOP_APPLE_API_KEY_ID, + appleApiIssuer: macNotarization.PROPR_DESKTOP_APPLE_API_ISSUER_ID, + }, + } : {}), + ...(windowsSign ? { windowsSign } : {}), }, rebuildConfig: {}, hooks: { + readPackageJson: async (_forgeConfig, packageJson) => ({ + ...packageJson, + version: releaseVersion, + }), packageAfterCopy: async (_forgeConfig, resourcesPath, _electronVersion, platform, arch) => { const applePlatform = platform === 'darwin' || platform === 'mas'; const executableName = applePlatform ? 'Electron' : `electron${platform === 'win32' ? '.exe' : ''}`; @@ -33,12 +118,83 @@ const config: ForgeConfig = { [FuseV1Options.WasmTrapHandlers]: true, }); }, + postPackage: async (_forgeConfig, packageResult) => { + if (packageResult.platform !== 'win32') return; + // The Windows signer runs after extra resources are copied and signs every + // PE in the application. Bind the manifest to those final signed helper + // bytes before Squirrel/checksum assembly consumes the packaged layout. + const authorityInspectorModule = './scripts/inspect-packaged-windows-authority.mjs'; + const { refreshPackagedWindowsAuthorityManifest, inspectPackagedWindowsAuthority } = await import( + authorityInspectorModule + ); + const { sealWindowsAuthorityDirectory } = await import('./scripts/build-windows-native-launcher.mjs'); + for (const outputPath of packageResult.outputPaths) { + const helperDirectory = resolve(outputPath, 'resources', 'windows-authority'); + const executable = resolve(helperDirectory, 'propr-windows-authority.exe'); + const manifest = resolve(helperDirectory, 'propr-windows-authority.manifest.json'); + await refreshPackagedWindowsAuthorityManifest(executable, manifest); + await inspectPackagedWindowsAuthority(executable, manifest); + await sealWindowsAuthorityDirectory(helperDirectory); + } + }, + postMake: async (_forgeConfig, makeResults) => { + if (process.platform !== 'win32') return makeResults; + const installerModule = './scripts/build-windows-machine-installer.mjs'; + const { buildWindowsMachineInstaller } = await import(installerModule); + for (const result of makeResults) { + if (result.platform !== 'win32' || (result.arch !== 'x64' && result.arch !== 'arm64')) continue; + const setup = result.artifacts.find(path => path.endsWith('Setup.exe')); + if (!setup) throw new Error('Squirrel output is missing its canonical setup executable'); + const machineInstaller = resolve( + setup, + '..', + `ProPR-Desktop-${releaseVersion}-Machine-Setup.msi`, + ); + const built = await buildWindowsMachineInstaller({ + appDirectory: resolve('out', `propr-desktop-win32-${result.arch}`), + output: machineInstaller, + version: releaseVersion, + arch: result.arch, + }); + if (built.skipped) throw new Error('Machine-wide Windows installer was not built'); + if (windowsSign) { + const { sign } = await import('@electron/windows-sign'); + await sign({ files: [machineInstaller], ...windowsSign }); + } + result.artifacts.push(machineInstaller); + } + return makeResults; + }, }, makers: [ - new MakerSquirrel({ name: 'propr_desktop' }), + new MakerSquirrel({ + name: SQUIRREL_PACKAGE_NAME, + setupExe: `ProPR-Desktop-${releaseVersion}-Setup.exe`, + noMsi: true, + version: releaseVersion, + ...(windowsSign ? { windowsSign } : {}), + }), new MakerZIP({}, ['darwin', 'linux']), - ...(process.env.PROPR_DESKTOP_ENABLE_DEB === '1' ? [new MakerDeb({})] : []), - ...(process.env.PROPR_DESKTOP_ENABLE_RPM === '1' ? [new MakerRpm({})] : []), + ...(process.env.PROPR_DESKTOP_ENABLE_DEB === '1' + ? [new MakerDeb({ + options: { + name: DESKTOP_EXECUTABLE_NAME, + productName: 'ProPR Desktop', + version: releaseVersion, + bin: DESKTOP_EXECUTABLE_NAME, + }, + })] + : []), + ...(process.env.PROPR_DESKTOP_ENABLE_RPM === '1' + ? [new MakerRpm({ + options: { + name: DESKTOP_EXECUTABLE_NAME, + productName: 'ProPR Desktop', + version: releaseVersion, + bin: DESKTOP_EXECUTABLE_NAME, + }, + })] + : []), ], plugins: [ new VitePlugin({ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c82d40083..93dc1b756 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,17 +10,23 @@ "type": "module", "main": ".vite/build/main.cjs", "scripts": { + "broker:build": "node scripts/build-windows-authority-helper.mjs", "prepare:renderer": "npm run build -w @propr/shared && npm run build -w @propr/client", "predev": "npm run prepare:renderer", "dev": "electron-forge start", "pretypecheck": "npm run prepare:renderer", "typecheck": "tsc --noEmit", - "test": "tsx --test src/**/*.test.ts", - "prepackage": "npm run prepare:renderer", + "pretest": "npm run broker:build", + "test": "tsx --test src/**/*.test.ts scripts/*.test.mjs", + "prepackage": "npm run broker:build && npm run prepare:renderer", "package": "electron-forge package", "smoke:package": "node scripts/smoke-packaged.mjs", - "premake": "npm run prepare:renderer", + "smoke:inspect": "node scripts/smoke-packaged.mjs --inspect-only", + "premake": "npm run broker:build && npm run prepare:renderer", "make": "electron-forge make", + "make:dmg": "node scripts/make-dmg.mjs", + "release:stage": "node scripts/release-artifacts.mjs stage", + "release:finalize": "node scripts/release-artifacts.mjs finalize", "premake:deb": "npm run prepare:renderer", "make:deb": "PROPR_DESKTOP_ENABLE_DEB=1 electron-forge make --targets @electron-forge/maker-deb", "premake:rpm": "npm run prepare:renderer", diff --git a/apps/desktop/scripts/build-windows-authority-helper.mjs b/apps/desktop/scripts/build-windows-authority-helper.mjs new file mode 100644 index 000000000..31ddf71d3 --- /dev/null +++ b/apps/desktop/scripts/build-windows-authority-helper.mjs @@ -0,0 +1,460 @@ +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { chmod, lstat, mkdir, mkdtemp, open, realpath, rename, rm, stat } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { + buildWindowsNativeLauncher, + prepareWindowsAuthorityBuildDirectory, + sealWindowsAuthorityDirectory, +} from './build-windows-native-launcher.mjs'; + +const desktopRoot = fileURLToPath(new URL('..', import.meta.url)); +export const WINDOWS_AUTHORITY_SOURCE = join(desktopRoot, 'src', 'native', 'propr-windows-authority.cs'); +export const WINDOWS_AUTHORITY_BUILD_DIRECTORY = join(desktopRoot, 'build', 'windows-authority'); +export const WINDOWS_AUTHORITY_EXECUTABLE = join(WINDOWS_AUTHORITY_BUILD_DIRECTORY, 'propr-windows-authority.exe'); +export const WINDOWS_AUTHORITY_MANIFEST = join(WINDOWS_AUTHORITY_BUILD_DIRECTORY, 'propr-windows-authority.manifest.json'); +export const WINDOWS_AUTHORITY_BUILD_STAGES = Object.freeze(['BUILD_COMPILER', 'BUILD_SOURCE', 'BUILD_OUTPUT']); +export const WINDOWS_AUTHORITY_COMPILER_SUBSTAGES = Object.freeze([ + 'DIRECTORY_PROBE', 'CATALOG_ENUMERATION', 'MEMBER_TAG', 'CATALOG_HASH', 'WINTRUST_POLICY', + 'REVOCATION', 'CATALOG_LEASE', 'SIGNER_PARSE', 'EXACT_PUBLISHER', 'ROOT_PIN', 'CERTIFICATE_PIN', + 'SPKI_PIN', 'COMPILER_OPEN', 'REFERENCE_OPEN', 'SIGNER_CATALOG', 'LEASE', 'SOURCE_COPY', 'SPAWN', + 'IMAGE', 'EXIT', 'OUTPUT_VALIDATION', +]); +const MAX_SOURCE_BYTES = 256 * 1024; +const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +const MAX_BUILD_INPUT_BYTES = 32 * 1024 * 1024; +const SYSTEM_DIRECTORY_RECORD_BYTES = 2 + (520 * 2); +const MICROSOFT_COMPILER_CATALOG_POLICY = Object.freeze([ + 'csc.exe', 'System.dll', 'System.Web.Extensions.dll', +].map(name => Object.freeze({ + name, + catalogName: process.arch === 'arm64' + ? 'Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat' + : 'Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat', + certificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + spkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + catalogSha256: process.arch === 'arm64' + ? 'fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85' + : 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef', +}))); +const require = createRequire(import.meta.url); + +const fail = (stage, substage) => { + const boundedSubstage = stage === 'BUILD_COMPILER' && WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(substage) + ? `:${substage}` : ''; + const error = new Error(`Windows authority helper build failed [win-authority:${stage}${boundedSubstage}]`); + error.stage = stage; + if (boundedSubstage) error.substage = substage; + throw error; +}; + +export const preserveWindowsAuthorityCompilerFailure = (error, fallback = 'DIRECTORY_PROBE') => { + if (typeof error === 'object' && error !== null) { + if (error.stage === 'BUILD_COMPILER' && WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(error.substage)) { + fail('BUILD_COMPILER', error.substage); + } + if (WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(error.code)) fail('BUILD_COMPILER', error.code); + } + fail('BUILD_COMPILER', fallback); +}; + +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const isProofArray = (value, pattern) => Array.isArray(value) && value.length === 3 + && value.every(entry => typeof entry === 'string' && pattern.test(entry)); +const samePath = (left, right) => process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; + +export const validateWindowsAuthoritySource = bytes => { + if (!Buffer.isBuffer(bytes) || bytes.length <= 0 || bytes.length > MAX_SOURCE_BYTES + || Buffer.from(bytes.toString('utf8'), 'utf8').compare(bytes) !== 0 + || !bytes.toString('utf8').includes('public static int Main(string[] args)')) fail('BUILD_SOURCE'); + return sha256(bytes); +}; + +const validateTree = async (root, target, stage) => { + const canonicalRoot = await realpath(root).catch(() => fail(stage)); + const canonicalTarget = await realpath(target).catch(() => fail(stage)); + if (!samePath(resolve(root), canonicalRoot) || !samePath(resolve(target), canonicalTarget)) fail(stage); + const inside = relative(canonicalRoot, canonicalTarget); + if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) fail(stage); + let cursor = canonicalRoot; + for (const component of inside.split(sep)) { + cursor = join(cursor, component); + const entry = await lstat(cursor).catch(() => fail(stage)); + if (entry.isSymbolicLink() || (!entry.isDirectory() && cursor !== canonicalTarget)) fail(stage); + } + const targetStats = await stat(canonicalTarget).catch(() => fail(stage)); + if (!targetStats.isFile() || targetStats.size <= 0) fail(stage); + return canonicalTarget; +}; + +const readHeldBuildOutput = async (root, target) => { + const canonical = await validateTree(root, target, 'BUILD_OUTPUT'); + const pathStats = await lstat(canonical, { bigint: true }).catch(() => fail('BUILD_OUTPUT')); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_OUTPUT_BYTES)) fail('BUILD_OUTPUT'); + const handle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(() => fail('BUILD_OUTPUT')); + try { + const before = await handle.stat({ bigint: true }); + if (before.dev !== pathStats.dev || before.ino !== pathStats.ino || before.size !== pathStats.size + || before.nlink !== pathStats.nlink) fail('BUILD_OUTPUT'); + const bytes = await handle.readFile(); + const after = await handle.stat({ bigint: true }); + if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size + || after.nlink !== before.nlink || BigInt(bytes.length) !== before.size) fail('BUILD_OUTPUT'); + return bytes; + } finally { await handle.close(); } +}; + +export const decodeWindowsSystemDirectoryRecord = record => { + if (!Buffer.isBuffer(record) || record.length !== SYSTEM_DIRECTORY_RECORD_BYTES) fail('BUILD_COMPILER'); + const length = record.readUInt16LE(0); + if (length < 3 || length >= 520) fail('BUILD_COMPILER'); + const pathBytes = record.subarray(2, 2 + (length * 2)); + if (record.subarray(2 + (length * 2)).some(byte => byte !== 0)) fail('BUILD_COMPILER'); + const path = pathBytes.toString('utf16le'); + if (!/^[A-Za-z]:\\[^\0]+$/.test(path) || path.startsWith('\\\\') || path.includes('\0') + || path.indexOf(':', 2) >= 0) fail('BUILD_COMPILER'); + return path; +}; + +const loadAuthenticatedNativeLauncher = launcher => { + let bootstrap; + try { bootstrap = require(launcher.bootstrap.path); } + catch { fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); } + if (!bootstrap || typeof bootstrap.loadVerifiedModule !== 'function') fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + try { + return bootstrap.loadVerifiedModule({ + path: launcher.path, + size: launcher.size, + sha256: launcher.sha256, + production: false, + publisher: null, + signerCertificateSha256: null, + signerSpkiSha256: null, + }); + } catch { return fail('BUILD_COMPILER', 'LEASE'); } +}; + +export const resolveWindowsCompilerLayout = async (env, probe) => { + // The native boundary returns one fixed-size UTF-16 record from + // GetSystemWindowsDirectoryW, after opening and authenticating the canonical + // system PowerShell image. Environment roots are disagreement checks only. + let reportedRoot; + try { + reportedRoot = await Promise.resolve().then(() => probe(env)); + } catch (error) { preserveWindowsAuthorityCompilerFailure(error); } + const canonicalRoot = await realpath(reportedRoot).catch(() => fail('BUILD_COMPILER', 'DIRECTORY_PROBE')); + if (!samePath(resolve(reportedRoot), canonicalRoot)) fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + for (const hint of [env.SystemRoot, env.windir]) { + if (hint && (!isAbsolute(hint) || !samePath(await realpath(hint) + .catch(() => fail('BUILD_COMPILER', 'DIRECTORY_PROBE')), canonicalRoot))) { + fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + } + } + const layouts = ['Framework64', 'Framework']; + let compilerFound = false; + for (const layout of layouts) { + const framework = join(canonicalRoot, 'Microsoft.NET', layout, 'v4.0.30319'); + const compiler = join(framework, 'csc.exe'); + const systemReference = join(framework, 'System.dll'); + const webReference = join(framework, 'System.Web.Extensions.dll'); + try { + const canonicalCompiler = await validateTree(canonicalRoot, compiler, 'BUILD_COMPILER'); + compilerFound = true; + return { + systemRoot: canonicalRoot, + compiler: canonicalCompiler, + framework, + systemReference: await validateTree(canonicalRoot, systemReference, 'BUILD_COMPILER'), + webReference: await validateTree(canonicalRoot, webReference, 'BUILD_COMPILER'), + }; + } catch { /* try the other trusted SystemRoot framework layout */ } + } + return fail('BUILD_COMPILER', compilerFound ? 'REFERENCE_OPEN' : 'COMPILER_OPEN'); +}; + +const holdBuildInput = async (root, path, name) => { + const canonical = await validateTree(root, path, 'BUILD_COMPILER'); + const pathStats = await lstat(canonical, { bigint: true }).catch(() => fail('BUILD_COMPILER')); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink < 1n || pathStats.size <= 0n + || pathStats.size > BigInt(MAX_BUILD_INPUT_BYTES)) fail('BUILD_COMPILER'); + const handle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => fail('BUILD_COMPILER')); + try { + const before = await handle.stat({ bigint: true }); + const bytes = await handle.readFile(); + if (before.dev !== pathStats.dev || before.ino !== pathStats.ino || before.size !== pathStats.size + || before.nlink < 1n || before.nlink !== pathStats.nlink || BigInt(bytes.length) !== before.size) fail('BUILD_COMPILER'); + return { name, path: canonical, handle, before, bytes, sha256: sha256(bytes) }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +}; + +const reverifyBuildInput = async input => { + const after = await input.handle.stat({ bigint: true }).catch(() => fail('BUILD_COMPILER')); + const pathStats = await lstat(input.path, { bigint: true }).catch(() => fail('BUILD_COMPILER')); + if (after.dev !== input.before.dev || after.ino !== input.before.ino || after.size !== input.before.size + || after.nlink < 1n || after.nlink !== input.before.nlink || pathStats.dev !== after.dev || pathStats.ino !== after.ino + || pathStats.size !== after.size || pathStats.nlink !== after.nlink) fail('BUILD_COMPILER'); + const bytes = await readHeldExactlyForBuild(input.handle, Number(after.size)); + if (sha256(bytes) !== input.sha256) fail('BUILD_COMPILER'); +}; + +const readHeldExactlyForBuild = async (handle, size, stage = 'BUILD_COMPILER') => { + const bytes = Buffer.alloc(size); + let offset = 0; + while (offset < size) { + const result = await handle.read(bytes, offset, size - offset, offset).catch(() => fail(stage)); + if (result.bytesRead <= 0) fail(stage); + offset += result.bytesRead; + } + return bytes; +}; + +const holdSourceInput = async () => { + const canonical = await realpath(WINDOWS_AUTHORITY_SOURCE).catch(() => fail('BUILD_SOURCE')); + if (!samePath(canonical, resolve(WINDOWS_AUTHORITY_SOURCE))) fail('BUILD_SOURCE'); + const pathStats = await lstat(canonical, { bigint: true }).catch(() => fail('BUILD_SOURCE')); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_SOURCE_BYTES)) fail('BUILD_SOURCE'); + const handle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(() => fail('BUILD_SOURCE')); + try { + const before = await handle.stat({ bigint: true }); + const bytes = await readHeldExactlyForBuild(handle, Number(before.size), 'BUILD_SOURCE'); + if (before.dev !== pathStats.dev || before.ino !== pathStats.ino || before.size !== pathStats.size + || before.nlink !== 1n || BigInt(bytes.length) !== before.size) fail('BUILD_SOURCE'); + return { path: canonical, handle, before, bytes, sha256: validateWindowsAuthoritySource(bytes) }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +}; + +const reverifySourceInput = async source => { + const after = await source.handle.stat({ bigint: true }).catch(() => fail('BUILD_SOURCE')); + const pathStats = await lstat(source.path, { bigint: true }).catch(() => fail('BUILD_SOURCE')); + if (after.dev !== source.before.dev || after.ino !== source.before.ino || after.size !== source.before.size + || after.nlink !== 1n || pathStats.dev !== after.dev || pathStats.ino !== after.ino + || pathStats.size !== after.size || pathStats.nlink !== 1n) fail('BUILD_SOURCE'); + const bytes = await readHeldExactlyForBuild(source.handle, Number(after.size), 'BUILD_SOURCE'); + if (sha256(bytes) !== source.sha256) fail('BUILD_SOURCE'); +}; + +const compilerSubstage = error => { + const code = typeof error === 'object' && error !== null && typeof error.code === 'string' ? error.code : ''; + return WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.includes(code) ? code : 'SPAWN'; +}; + +export const inspectAnyCpuPe = bytes => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > MAX_OUTPUT_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) fail('BUILD_OUTPUT'); + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset < 0x40 || peOffset + 248 > bytes.length || bytes.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') { + fail('BUILD_OUTPUT'); + } + const machine = bytes.readUInt16LE(peOffset + 4); + const sectionCount = bytes.readUInt16LE(peOffset + 6); + const optionalSize = bytes.readUInt16LE(peOffset + 20); + const optional = peOffset + 24; + if (machine !== 0x14c || sectionCount <= 0 || sectionCount > 96 + || optionalSize < 224 || bytes.readUInt16LE(optional) !== 0x10b) fail('BUILD_OUTPUT'); + const clrDirectory = optional + 96 + (14 * 8); + const clrRva = bytes.readUInt32LE(clrDirectory); + if (clrDirectory + 8 > optional + optionalSize || clrRva === 0 || bytes.readUInt32LE(clrDirectory + 4) < 72) fail('BUILD_OUTPUT'); + const sectionTable = optional + optionalSize; + let clrOffset = -1; + for (let index = 0; index < sectionCount; index += 1) { + const section = sectionTable + (index * 40); + if (section + 40 > bytes.length) fail('BUILD_OUTPUT'); + const virtualSize = bytes.readUInt32LE(section + 8); + const virtualAddress = bytes.readUInt32LE(section + 12); + const rawSize = bytes.readUInt32LE(section + 16); + const rawAddress = bytes.readUInt32LE(section + 20); + const span = Math.max(virtualSize, rawSize); + if (clrRva >= virtualAddress && clrRva < virtualAddress + span) clrOffset = rawAddress + clrRva - virtualAddress; + } + if (clrOffset < 0 || clrOffset + 20 > bytes.length) fail('BUILD_OUTPUT'); + const corFlags = bytes.readUInt32LE(clrOffset + 16); + if ((corFlags & 0x1) === 0 || (corFlags & (0x2 | 0x10 | 0x20000)) !== 0) fail('BUILD_OUTPUT'); + return { format: 'PE32', architecture: 'anycpu', machine: 'I386', clr: true }; +}; + +const writeAtomic = async (target, bytes) => { + const temporary = `${target}.${process.pid}.${Date.now()}.tmp`; + const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, target); +}; + +export const buildWindowsAuthorityHelper = async (env = process.env) => { + if (process.platform !== 'win32') return { skipped: true }; + await prepareWindowsAuthorityBuildDirectory(); + const launcher = await buildWindowsNativeLauncher().catch(error => preserveWindowsAuthorityCompilerFailure(error)); + if (launcher.skipped) fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + const nativeLauncher = loadAuthenticatedNativeLauncher(launcher); + const { systemRoot, compiler, framework, systemReference, webReference } = await resolveWindowsCompilerLayout( + env, + probeEnv => { + if (!nativeLauncher || typeof nativeLauncher.probeSystemDirectory !== 'function') { + return fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); + } + let record; + try { record = nativeLauncher.probeSystemDirectory({ systemRoot: probeEnv.SystemRoot ?? '', windir: probeEnv.windir ?? '', + fault: probeEnv.PROPR_WINDOWS_AUTHORITY_TEST_DIRECTORY_PROBE_FAULT ?? null }); } + catch (error) { return preserveWindowsAuthorityCompilerFailure(error, + compilerSubstage(error) === 'SPAWN' ? 'DIRECTORY_PROBE' : compilerSubstage(error)); } + try { return decodeWindowsSystemDirectoryRecord(record); } + catch { return fail('BUILD_COMPILER', 'DIRECTORY_PROBE'); } + }, + ); + const sourceInput = await holdSourceInput(); + const sourceSha256 = sourceInput.sha256; + await mkdir(WINDOWS_AUTHORITY_BUILD_DIRECTORY, { recursive: true }); + const privateOutputDirectory = await mkdtemp(join(WINDOWS_AUTHORITY_BUILD_DIRECTORY, 'compile-')); + await chmod(privateOutputDirectory, 0o700).catch(() => fail('BUILD_OUTPUT')); + const temporaryOutput = join(privateOutputDirectory, 'propr-windows-authority.exe'); + const buildInputs = []; + let publicationComplete = false; + try { + try { buildInputs.push(await holdBuildInput(systemRoot, compiler, 'csc.exe')); } + catch { fail('BUILD_COMPILER', 'COMPILER_OPEN'); } + try { + buildInputs.push(await holdBuildInput(systemRoot, systemReference, 'System.dll')); + buildInputs.push(await holdBuildInput(systemRoot, webReference, 'System.Web.Extensions.dll')); + } catch { fail('BUILD_COMPILER', 'REFERENCE_OPEN'); } + await Promise.all(buildInputs.map(reverifyBuildInput)).catch(() => fail('BUILD_COMPILER', 'LEASE')); + await reverifySourceInput(sourceInput); + const frameworkIdentity = framework.toLowerCase().endsWith(`${sep}framework64${sep}v4.0.30319`.toLowerCase()) + ? 'Framework64-v4.0.30319' + : 'Framework-v4.0.30319'; + if (!nativeLauncher || typeof nativeLauncher.compileHeld !== 'function') fail('BUILD_COMPILER', 'SPAWN'); + let compileProof; + try { + compileProof = nativeLauncher.compileHeld({ + systemRoot, + paths: buildInputs.map(input => input.path), + sizes: buildInputs.map(input => Number(input.before.size)), + sha256: buildInputs.map(input => input.sha256), + source: sourceInput.bytes, + output: temporaryOutput, + cwd: privateOutputDirectory, + fault: env.PROPR_WINDOWS_AUTHORITY_TEST_COMPILER_FAULT ?? null, + }); + } catch (error) { fail('BUILD_COMPILER', compilerSubstage(error)); } + await Promise.all(buildInputs.map(reverifyBuildInput)).catch(() => fail('BUILD_COMPILER', 'LEASE')); + await reverifySourceInput(sourceInput); + const output = await readHeldBuildOutput(privateOutputDirectory, temporaryOutput); + const pe = inspectAnyCpuPe(output); + if (output.length <= 0 || output.length > MAX_OUTPUT_BYTES + || compileProof.size !== output.length || compileProof.sha256 !== sha256(output) + || !/^[a-f0-9]{64}$/.test(String(compileProof.compilerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(compileProof.compilerSpkiSha256)) + || !/^[a-f0-9]{64}$/.test(String(compileProof.compilerRootSpkiSha256)) + || !/^[a-f0-9]{16}$/.test(String(compileProof.compilerVolumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(compileProof.compilerFileId128)) + || !isProofArray(compileProof.inputCertificateSha256, /^[a-f0-9]{64}$/) + || !isProofArray(compileProof.inputSpkiSha256, /^[a-f0-9]{64}$/) + || !isProofArray(compileProof.inputRootSpkiSha256, /^[a-f0-9]{64}$/) + || !isProofArray(compileProof.inputCatalogName, /^[A-Za-z0-9_.~-]{1,180}\.cat$/) + || !isProofArray(compileProof.inputCatalogSha256, /^[a-f0-9]{64}$/) + || !isProofArray(compileProof.inputCatalogVolumeSerial, /^[a-f0-9]{16}$/) + || !isProofArray(compileProof.inputCatalogFileId128, /^[a-f0-9]{32}$/) + || buildInputs.some((input, index) => { + const approved = MICROSOFT_COMPILER_CATALOG_POLICY.find(entry => entry.name === input.name); + return !approved || compileProof.inputCertificateSha256[index] !== approved.certificateSha256 + || compileProof.inputSpkiSha256[index] !== approved.spkiSha256 + || compileProof.inputCatalogName[index] !== approved.catalogName + || compileProof.inputCatalogSha256[index] !== approved.catalogSha256; + })) fail('BUILD_OUTPUT'); + await writeAtomic(WINDOWS_AUTHORITY_EXECUTABLE, output); + const publishedOutput = await readHeldBuildOutput(WINDOWS_AUTHORITY_BUILD_DIRECTORY, WINDOWS_AUTHORITY_EXECUTABLE); + if (!publishedOutput.equals(output)) fail('BUILD_OUTPUT'); + const manifest = { + schemaVersion: 1, + name: 'propr-windows-authority.exe', + format: pe.format, + architecture: pe.architecture, + machine: pe.machine, + clr: pe.clr, + size: output.length, + sha256: sha256(output), + sourceSha256, + protocol: 'propr-windows-authority-v1', + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + launcher: { + name: launcher.name, + format: launcher.format, + architecture: launcher.architecture, + machine: launcher.machine, + size: launcher.size, + sha256: launcher.sha256, + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + bootstrap: { + name: launcher.bootstrap.name, + format: launcher.bootstrap.format, + architecture: launcher.bootstrap.architecture, + machine: launcher.bootstrap.machine, + size: launcher.bootstrap.size, + sha256: launcher.bootstrap.sha256, + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + compiler: { + kind: 'windows-catalog-authorized-dotnet-framework-csc-v1', + framework: frameworkIdentity, + signerCertificateSha256: compileProof.compilerCertificateSha256, + signerSpkiSha256: compileProof.compilerSpkiSha256, + signerRootSpkiSha256: compileProof.compilerRootSpkiSha256, + volumeSerial: compileProof.compilerVolumeSerial, + fileId128: compileProof.compilerFileId128, + inputs: buildInputs.map((input, index) => ({ + name: input.name, + size: Number(input.before.size), + sha256: input.sha256, + signerCertificateSha256: compileProof.inputCertificateSha256[index], + signerSpkiSha256: compileProof.inputSpkiSha256[index], + signerRootSpkiSha256: compileProof.inputRootSpkiSha256[index], + catalogName: compileProof.inputCatalogName[index], + catalogSha256: compileProof.inputCatalogSha256[index], + catalogVolumeSerial: compileProof.inputCatalogVolumeSerial[index], + catalogFileId128: compileProof.inputCatalogFileId128[index], + })), + }, + }; + await writeAtomic(WINDOWS_AUTHORITY_MANIFEST, Buffer.from(`${JSON.stringify(manifest)}\n`, 'utf8')); + publicationComplete = true; + return { skipped: false, executable: WINDOWS_AUTHORITY_EXECUTABLE, manifest: WINDOWS_AUTHORITY_MANIFEST, ...manifest }; + } finally { + await Promise.all(buildInputs.map(input => input.handle.close().catch(() => undefined))); + await sourceInput.handle.close().catch(() => undefined); + await rm(privateOutputDirectory, { recursive: true, force: true }); + if (publicationComplete) await sealWindowsAuthorityDirectory(); + } +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + buildWindowsAuthorityHelper().then(result => { + if (!result.skipped) process.stdout.write('Windows authority helper built and verified\n'); + }).catch(error => { + process.stderr.write(`${error instanceof Error ? error.message : 'Windows authority helper build failed'}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/desktop/scripts/build-windows-machine-installer.mjs b/apps/desktop/scripts/build-windows-machine-installer.mjs new file mode 100644 index 000000000..ee1364dc2 --- /dev/null +++ b/apps/desktop/scripts/build-windows-machine-installer.mjs @@ -0,0 +1,149 @@ +import { execFile } from 'node:child_process'; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const desktopRoot = fileURLToPath(new URL('..', import.meta.url)); +const repositoryRoot = resolve(desktopRoot, '..', '..'); +const wixVendor = join(repositoryRoot, 'node_modules', 'electron-winstaller', 'vendor'); +const MAX_FILES = 4096; +const MAX_PATH_BYTES = 32 * 1024; +const UPGRADE_CODE = '79D29087-5B38-4D77-93C8-5BC0F7856D59'; + +const fail = message => { throw new Error(`Windows machine installer build failed: ${message}`); }; +const xml = value => String(value).replaceAll('&', '&').replaceAll('<', '<') + .replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + +const collectTree = async root => { + const files = []; + const visit = async directory => { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name, 'en')); + for (const entry of entries) { + const path = join(directory, entry.name); + const stats = await lstat(path, { bigint: true }); + if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile())) fail('special packaged entry'); + if (stats.isDirectory()) await visit(path); + else { + const name = relative(root, path); + if (!name || Buffer.byteLength(name, 'utf8') > MAX_PATH_BYTES || stats.size < 0n) fail('invalid packaged entry'); + files.push({ path, name, size: stats.size }); + if (files.length > MAX_FILES) fail('packaged entry bound'); + } + } + }; + await visit(root); + if (!files.some(entry => entry.name.toLowerCase() === 'propr-desktop.exe')) fail('canonical executable missing'); + for (const name of ['propr-windows-authority.exe', 'propr-windows-authority.manifest.json', + 'propr-windows-launcher.node', 'propr-windows-bootstrap.node']) { + if (!files.some(entry => entry.name.toLowerCase() === `resources\\windows-authority\\${name}`.toLowerCase())) { + fail('machine authority incomplete'); + } + } + return files; +}; + +const directoryXml = files => { + const root = { children: new Map(), files: [] }; + for (const file of files) { + const parts = file.name.split('\\'); + let cursor = root; + for (const part of parts.slice(0, -1)) { + if (!cursor.children.has(part)) cursor.children.set(part, { children: new Map(), files: [] }); + cursor = cursor.children.get(part); + } + cursor.files.push(file); + } + let next = 0; + const components = []; + const render = (node, indent) => { + const lines = []; + for (const [name, child] of node.children) { + const directoryId = `D${next++}`; + lines.push(`${indent}`); + lines.push(render(child, `${indent} `)); + lines.push(`${indent}`); + } + for (const file of node.files) { + const componentId = `C${next++}`; + const fileId = `F${next++}`; + components.push(componentId); + lines.push(`${indent}`); + lines.push(`${indent} `); + lines.push(`${indent}`); + } + return lines.join('\n'); + }; + return { content: render(root, ' '), components }; +}; + +const sourceFor = (appDirectory, version, arch, files) => { + const tree = directoryXml(files); + const platform = arch === 'arm64' ? 'arm64' : 'x64'; + const productCode = '*'; + const sealTarget = '[INSTALLFOLDER]'; + const users = '*S-1-5-32-545:(OI)(CI)RX'; + const administrators = '*S-1-5-32-544:(OI)(CI)RX'; + const system = '*S-1-5-18:(OI)(CI)F'; + const trustedInstaller = '*S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464:(OI)(CI)F'; + return ` + + + + + + + + +${tree.content} + + + + +${tree.components.map(id => ` `).join('\n')} + + + + + + + NOT REMOVE + NOT REMOVE + NOT REMOVE + NOT REMOVE + + + +`; +}; + +export const buildWindowsMachineInstaller = async ({ appDirectory, output, version, arch }) => { + if (process.platform !== 'win32') return { skipped: true }; + if (!['x64', 'arm64'].includes(arch) || !/^\d+\.\d+\.\d+$/.test(version)) fail('arguments'); + const canonicalApp = resolve(appDirectory); + const files = await collectTree(canonicalApp); + const temporary = await mkdtemp(join(dirname(output), '.machine-installer-')); + try { + const source = join(temporary, 'propr-desktop.wxs'); + const object = join(temporary, 'propr-desktop.wixobj'); + await writeFile(source, sourceFor(canonicalApp, version, arch, files), { encoding: 'utf8', flag: 'wx' }); + await execFileAsync(join(wixVendor, 'candle.exe'), ['-nologo', '-arch', arch, '-out', object, source], { + cwd: temporary, windowsHide: true, timeout: 120_000, maxBuffer: 64 * 1024, + }); + await mkdir(dirname(output), { recursive: true }); + await execFileAsync(join(wixVendor, 'light.exe'), ['-nologo', '-out', output, object], { + cwd: temporary, windowsHide: true, timeout: 120_000, maxBuffer: 64 * 1024, + }); + const bytes = await readFile(output); + if (bytes.length < 4096 || bytes.subarray(0, 8).toString('hex') !== 'd0cf11e0a1b11ae1') fail('invalid MSI output'); + return { skipped: false, path: output, files: files.length }; + } finally { await rm(temporary, { recursive: true, force: true }); } +}; + diff --git a/apps/desktop/scripts/build-windows-native-launcher.d.mts b/apps/desktop/scripts/build-windows-native-launcher.d.mts new file mode 100644 index 000000000..f80433c7b --- /dev/null +++ b/apps/desktop/scripts/build-windows-native-launcher.d.mts @@ -0,0 +1,15 @@ +export const WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY: string; +export const WINDOWS_NATIVE_LAUNCHER: string; +export const WINDOWS_NATIVE_BOOTSTRAP: string; +export const WINDOWS_NATIVE_AUTHORITY_DIRECTORY: string; + +export function prepareWindowsAuthorityBuildDirectory(root?: string): Promise; +export function sealWindowsAuthorityDirectory(root?: string): Promise; + +export function inspectWindowsNativeLauncherPe(bytes: Buffer, expectedArchitecture: string): { + format: 'PE'; + architecture: string; + machine: 'ARM64' | 'AMD64'; +}; + +export function buildWindowsNativeLauncher(): Promise>; diff --git a/apps/desktop/scripts/build-windows-native-launcher.mjs b/apps/desktop/scripts/build-windows-native-launcher.mjs new file mode 100644 index 000000000..fd51fac64 --- /dev/null +++ b/apps/desktop/scripts/build-windows-native-launcher.mjs @@ -0,0 +1,156 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { copyFile, lstat, mkdir, open, realpath } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const desktopRoot = fileURLToPath(new URL('..', import.meta.url)); +const repositoryRoot = resolve(desktopRoot, '..', '..'); +export const WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY = join(desktopRoot, 'src', 'native', 'windows-launcher'); +export const WINDOWS_NATIVE_LAUNCHER = join(desktopRoot, 'build', 'windows-authority', 'propr-windows-launcher.node'); +export const WINDOWS_NATIVE_BOOTSTRAP = join(desktopRoot, 'build', 'windows-authority', 'propr-windows-bootstrap.node'); +export const WINDOWS_NATIVE_AUTHORITY_DIRECTORY = join(desktopRoot, 'build', 'windows-authority'); +const MAX_LAUNCHER_BYTES = 4 * 1024 * 1024; +const KERNEL_TAKEOWN = String.raw`\\?\GLOBALROOT\SystemRoot\System32\takeown.exe`; +const KERNEL_ICACLS = String.raw`\\?\GLOBALROOT\SystemRoot\System32\icacls.exe`; +const SYSTEM_SID = '*S-1-5-18'; +const ADMINISTRATORS_SID = '*S-1-5-32-544'; +const TRUSTED_INSTALLER_SID = '*S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'; + +const fail = (substage = 'OUTPUT_VALIDATION') => { + const error = new Error(`Windows authority helper build failed [win-authority:BUILD_COMPILER:${substage}]`); + error.stage = 'BUILD_COMPILER'; + error.substage = substage; + error.code = substage; + throw error; +}; +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +const authorityAclTool = async (tool, args) => { + await execFileAsync(tool, args, { + windowsHide: true, + timeout: 30_000, + maxBuffer: 64 * 1024, + env: {}, + }).catch(() => fail('DIRECTORY_PROBE')); +}; + +const exactAuthorityDirectory = async root => { + const pathStats = await lstat(root).catch(() => null); + if (!pathStats) return false; + if (!pathStats.isDirectory() || pathStats.isSymbolicLink() + || (await realpath(root).catch(() => fail('DIRECTORY_PROBE'))).toLowerCase() !== resolve(root).toLowerCase()) { + fail('DIRECTORY_PROBE'); + } + return true; +}; + +// Build steps are the only writers. Reopening a previously sealed tree is an +// explicit trusted-build transition, never part of runtime authorization. +export const prepareWindowsAuthorityBuildDirectory = async (root = WINDOWS_NATIVE_AUTHORITY_DIRECTORY) => { + if (process.platform !== 'win32' || !(await exactAuthorityDirectory(root))) return; + await authorityAclTool(KERNEL_TAKEOWN, ['/F', root, '/A', '/R', '/SKIPSL']); + await authorityAclTool(KERNEL_ICACLS, [root, '/inheritance:r', '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/grant:r', `${ADMINISTRATORS_SID}:(OI)(CI)F`, + `${SYSTEM_SID}:(OI)(CI)F`, '/T', '/C', '/Q']); +}; + +// Publish an OS-owned, protected, read/execute-only application authority. +// The verifier independently re-reads every effective explicit and inherited +// ACE from held handles; these setup operations are never accepted as proof. +export const sealWindowsAuthorityDirectory = async (root = WINDOWS_NATIVE_AUTHORITY_DIRECTORY) => { + if (process.platform !== 'win32' || !(await exactAuthorityDirectory(root))) fail('DIRECTORY_PROBE'); + // Reset first so an explicit SID planted during the build cannot survive the + // transition merely because /grant:r only replaces ACEs for named trustees. + await authorityAclTool(KERNEL_ICACLS, [root, '/reset', '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/inheritance:r', '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/grant:r', `${SYSTEM_SID}:(OI)(CI)F`, + `${TRUSTED_INSTALLER_SID}:(OI)(CI)F`, `${ADMINISTRATORS_SID}:(OI)(CI)RX`, '/T', '/C', '/Q']); + await authorityAclTool(KERNEL_ICACLS, [root, '/setowner', SYSTEM_SID, '/T', '/C', '/Q']); +}; + +export const inspectWindowsNativeLauncherPe = (bytes, expectedArchitecture) => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > MAX_LAUNCHER_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) fail(); + const pe = bytes.readUInt32LE(0x3c); + if (pe < 0x40 || pe + 24 > bytes.length || bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0') fail(); + const machine = bytes.readUInt16LE(pe + 4); + const expectedMachine = expectedArchitecture === 'arm64' ? 0xaa64 : expectedArchitecture === 'x64' ? 0x8664 : -1; + if (machine !== expectedMachine) fail(); + return { format: 'PE', architecture: expectedArchitecture, machine: expectedMachine === 0xaa64 ? 'ARM64' : 'AMD64' }; +}; + +const heldBytes = async path => { + const canonical = await realpath(path).catch(() => fail('OUTPUT_VALIDATION')); + if ((process.platform === 'win32' ? canonical.toLowerCase() : canonical) !== (process.platform === 'win32' + ? resolve(path).toLowerCase() : resolve(path))) fail('OUTPUT_VALIDATION'); + const pathStats = await lstat(path, { bigint: true }).catch(() => fail('OUTPUT_VALIDATION')); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_LAUNCHER_BYTES)) fail('OUTPUT_VALIDATION'); + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => fail('OUTPUT_VALIDATION')); + try { + const before = await handle.stat({ bigint: true }); + const bytes = await handle.readFile(); + const after = await handle.stat({ bigint: true }); + if (before.dev !== pathStats.dev || before.ino !== pathStats.ino || before.size !== pathStats.size + || before.nlink !== 1n || after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size + || BigInt(bytes.length) !== before.size) fail('OUTPUT_VALIDATION'); + return bytes; + } finally { await handle.close(); } +}; + +let launcherBuild; + +const buildWindowsNativeLauncherOnce = async () => { + if (process.platform !== 'win32') return { skipped: true }; + if (process.arch !== 'x64' && process.arch !== 'arm64') fail('OUTPUT_VALIDATION'); + await prepareWindowsAuthorityBuildDirectory(); + const nodeGyp = join(repositoryRoot, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'); + await execFileAsync(process.execPath, [nodeGyp, 'rebuild', '--directory', WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, + `--arch=${process.arch}`], { cwd: repositoryRoot, windowsHide: true, timeout: 120_000, maxBuffer: 64 * 1024 }) + .catch(() => fail('SPAWN')); + const built = join(WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, 'build', 'Release', 'propr_windows_launcher.node'); + const builtBootstrap = join(WINDOWS_NATIVE_LAUNCHER_SOURCE_DIRECTORY, 'build', 'Release', 'propr_windows_bootstrap.node'); + const bytes = await heldBytes(built); + const bootstrapBytes = await heldBytes(builtBootstrap); + const pe = inspectWindowsNativeLauncherPe(bytes, process.arch); + const bootstrapPe = inspectWindowsNativeLauncherPe(bootstrapBytes, process.arch); + await mkdir(WINDOWS_NATIVE_AUTHORITY_DIRECTORY, { recursive: true }); + await copyFile(built, WINDOWS_NATIVE_LAUNCHER); + await copyFile(builtBootstrap, WINDOWS_NATIVE_BOOTSTRAP); + const published = await heldBytes(WINDOWS_NATIVE_LAUNCHER); + const publishedBootstrap = await heldBytes(WINDOWS_NATIVE_BOOTSTRAP); + if (!published.equals(bytes) || !publishedBootstrap.equals(bootstrapBytes)) fail('OUTPUT_VALIDATION'); + return { + skipped: false, + path: WINDOWS_NATIVE_LAUNCHER, + name: 'propr-windows-launcher.node', + size: bytes.length, + sha256: sha256(bytes), + bootstrap: { + path: WINDOWS_NATIVE_BOOTSTRAP, + name: 'propr-windows-bootstrap.node', + size: bootstrapBytes.length, + sha256: sha256(bootstrapBytes), + ...bootstrapPe, + }, + ...pe, + }; +}; + +export const buildWindowsNativeLauncher = async () => { + if (process.platform !== 'win32') return { skipped: true }; + launcherBuild ??= buildWindowsNativeLauncherOnce().catch(error => { + launcherBuild = undefined; + throw error; + }); + return launcherBuild; +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await buildWindowsNativeLauncher(); +} diff --git a/apps/desktop/scripts/inspect-packaged-windows-authority.mjs b/apps/desktop/scripts/inspect-packaged-windows-authority.mjs new file mode 100644 index 000000000..48ab42ce6 --- /dev/null +++ b/apps/desktop/scripts/inspect-packaged-windows-authority.mjs @@ -0,0 +1,252 @@ +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, realpath, rename } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inspectAnyCpuPe } from './build-windows-authority-helper.mjs'; +import { inspectWindowsNativeLauncherPe } from './build-windows-native-launcher.mjs'; + +const EXECUTABLE_NAME = 'propr-windows-authority.exe'; +const MANIFEST_NAME = 'propr-windows-authority.manifest.json'; +const LAUNCHER_NAME = 'propr-windows-launcher.node'; +const BOOTSTRAP_NAME = 'propr-windows-bootstrap.node'; +const MANIFEST_KEYS = [ + 'schemaVersion', 'name', 'format', 'architecture', 'machine', 'clr', 'size', 'sha256', 'sourceSha256', + 'protocol', 'trust', 'publisher', 'compiler', + 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256', + 'bootstrap', 'launcher', +]; +const MAX_HELPER_BYTES = 4 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 16 * 1024; + +const fail = () => { throw new Error('Packaged Windows authority helper inspection failed'); }; +const exactKeys = (value, keys) => Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +const parseManifest = bytes => { + if (bytes.length <= 1 || bytes.length > MAX_MANIFEST_BYTES || bytes.at(-1) !== 0x0a) fail(); + let manifest; + try { manifest = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, -1))); } + catch { fail(); } + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || !exactKeys(manifest, MANIFEST_KEYS) + || !manifest.compiler || typeof manifest.compiler !== 'object' || Array.isArray(manifest.compiler) + || !manifest.launcher || typeof manifest.launcher !== 'object' || Array.isArray(manifest.launcher) + || !manifest.bootstrap || typeof manifest.bootstrap !== 'object' || Array.isArray(manifest.bootstrap) + || !exactKeys(manifest.compiler, ['kind', 'framework', 'signerCertificateSha256', 'signerSpkiSha256', + 'signerRootSpkiSha256', 'volumeSerial', 'fileId128', 'inputs']) || manifest.schemaVersion !== 1 + || !exactKeys(manifest.launcher, ['name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', + 'publisher', 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256']) + || !exactKeys(manifest.bootstrap, ['name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', + 'publisher', 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256']) + || manifest.name !== EXECUTABLE_NAME || manifest.format !== 'PE32' || manifest.architecture !== 'anycpu' + || manifest.machine !== 'I386' || manifest.clr !== true || !Number.isSafeInteger(manifest.size) + || manifest.size <= 0 || manifest.size > MAX_HELPER_BYTES || !/^[a-f0-9]{64}$/.test(manifest.sha256) + || !/^[a-f0-9]{64}$/.test(manifest.sourceSha256) || manifest.protocol !== 'propr-windows-authority-v1' + || !['unsigned-validation', 'production-signed'].includes(manifest.trust) + || (manifest.trust === 'unsigned-validation' && manifest.publisher !== null) + || (manifest.trust === 'production-signed' && (typeof manifest.publisher !== 'string' || !manifest.publisher)) + || !Array.isArray(manifest.signerPins) || manifest.signerPins.length > 16 + || manifest.signerPins.some(pin => typeof pin !== 'string' + || !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(manifest.signerPins).size !== manifest.signerPins.length + || manifest.signerPins.join(',') !== [...manifest.signerPins].sort().join(',') + || (manifest.trust === 'unsigned-validation' + && (manifest.signerPins.length !== 0 || manifest.signerCertificateSha256 !== null + || manifest.signerSpkiSha256 !== null)) + || (manifest.trust === 'production-signed' + && (manifest.signerPins.length === 0 + || !/^[a-f0-9]{64}$/.test(String(manifest.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(manifest.signerSpkiSha256)) + || !manifest.signerPins.some(pin => pin === `certificate-sha256:${manifest.signerCertificateSha256}` + || pin === `spki-sha256:${manifest.signerSpkiSha256}`))) + || manifest.launcher.name !== LAUNCHER_NAME || manifest.launcher.format !== 'PE' + || !['x64', 'arm64'].includes(manifest.launcher.architecture) + || (manifest.launcher.architecture === 'x64' ? manifest.launcher.machine !== 'AMD64' + : manifest.launcher.machine !== 'ARM64') + || !Number.isSafeInteger(manifest.launcher.size) || manifest.launcher.size <= 0 + || manifest.launcher.size > MAX_HELPER_BYTES || !/^[a-f0-9]{64}$/.test(manifest.launcher.sha256) + || manifest.launcher.trust !== manifest.trust || manifest.launcher.publisher !== manifest.publisher + || JSON.stringify(manifest.launcher.signerPins) !== JSON.stringify(manifest.signerPins) + || manifest.launcher.signerCertificateSha256 !== manifest.signerCertificateSha256 + || manifest.launcher.signerSpkiSha256 !== manifest.signerSpkiSha256 + || manifest.bootstrap.name !== BOOTSTRAP_NAME || manifest.bootstrap.format !== 'PE' + || manifest.bootstrap.architecture !== manifest.launcher.architecture + || manifest.bootstrap.machine !== manifest.launcher.machine + || !Number.isSafeInteger(manifest.bootstrap.size) || manifest.bootstrap.size <= 0 + || manifest.bootstrap.size > MAX_HELPER_BYTES || !/^[a-f0-9]{64}$/.test(manifest.bootstrap.sha256) + || manifest.bootstrap.trust !== manifest.trust || manifest.bootstrap.publisher !== manifest.publisher + || JSON.stringify(manifest.bootstrap.signerPins) !== JSON.stringify(manifest.signerPins) + || manifest.bootstrap.signerCertificateSha256 !== manifest.signerCertificateSha256 + || manifest.bootstrap.signerSpkiSha256 !== manifest.signerSpkiSha256 + || manifest.compiler.kind !== 'windows-catalog-authorized-dotnet-framework-csc-v1' + || !/^(?:Framework64|Framework)-v4\.0\.30319$/.test(manifest.compiler.framework) + || !/^[a-f0-9]{64}$/.test(manifest.compiler.signerCertificateSha256) + || !/^[a-f0-9]{64}$/.test(manifest.compiler.signerSpkiSha256) + || !/^[a-f0-9]{64}$/.test(manifest.compiler.signerRootSpkiSha256) + || !/^[a-f0-9]{16}$/.test(manifest.compiler.volumeSerial) + || !/^[a-f0-9]{32}$/.test(manifest.compiler.fileId128) + || !Array.isArray(manifest.compiler.inputs) || manifest.compiler.inputs.length !== 3 + || manifest.compiler.inputs.map(input => input?.name).join(',') !== 'csc.exe,System.dll,System.Web.Extensions.dll' + || manifest.compiler.inputs.some(input => !input || typeof input !== 'object' || Array.isArray(input) + || !exactKeys(input, ['name', 'size', 'sha256', 'signerCertificateSha256', 'signerSpkiSha256', + 'signerRootSpkiSha256', 'catalogName', 'catalogSha256', 'catalogVolumeSerial', 'catalogFileId128']) + || !Number.isSafeInteger(input.size) || input.size <= 0 || input.size > 32 * 1024 * 1024 + || !/^[a-f0-9]{64}$/.test(input.sha256) + || !/^[a-f0-9]{64}$/.test(input.signerCertificateSha256) + || !/^[a-f0-9]{64}$/.test(input.signerSpkiSha256) + || !/^[a-f0-9]{64}$/.test(input.signerRootSpkiSha256) + || input.signerCertificateSha256 !== '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de' + || input.signerSpkiSha256 !== 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1' + || !((manifest.launcher.architecture === 'x64' + && input.catalogName === 'Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat' + && input.catalogSha256 === 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef') + || (manifest.launcher.architecture === 'arm64' + && input.catalogName === 'Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat' + && input.catalogSha256 === 'fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85')) + || !/^[a-f0-9]{16}$/.test(input.catalogVolumeSerial) + || !/^[a-f0-9]{32}$/.test(input.catalogFileId128)) + || manifest.compiler.inputs[0].signerCertificateSha256 !== manifest.compiler.signerCertificateSha256 + || manifest.compiler.inputs[0].signerSpkiSha256 !== manifest.compiler.signerSpkiSha256 + || manifest.compiler.inputs[0].signerRootSpkiSha256 !== manifest.compiler.signerRootSpkiSha256) fail(); + return manifest; +}; + +const openCanonicalRegular = async (trustedRoot, path, expectedName) => { + const canonicalRoot = await realpath(trustedRoot).catch(fail); + const canonical = await realpath(path).catch(fail); + const expected = resolve(path); + const child = relative(canonicalRoot, canonical); + if (basename(path).toLowerCase() !== expectedName.toLowerCase() + || !child || child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child) + || (process.platform === 'win32' + ? canonicalRoot.toLowerCase() !== resolve(trustedRoot).toLowerCase() + : canonicalRoot !== resolve(trustedRoot)) + || (process.platform === 'win32' ? canonical.toLowerCase() !== expected.toLowerCase() : canonical !== expected)) fail(); + const pathStats = await lstat(path, { bigint: true }).catch(fail); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n) fail(); + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch(fail); + const heldStats = await handle.stat({ bigint: true }); + if (heldStats.dev !== pathStats.dev || heldStats.ino !== pathStats.ino || heldStats.size !== pathStats.size + || heldStats.nlink !== pathStats.nlink) { await handle.close(); fail(); } + return { handle, stats: heldStats }; +}; + +export const refreshPackagedWindowsAuthorityManifest = async (executablePath, manifestPath, env = process.env) => { + const trustedRoot = dirname(executablePath); + if (trustedRoot !== dirname(manifestPath)) fail(); + const executable = await openCanonicalRegular(trustedRoot, executablePath, EXECUTABLE_NAME); + const launcher = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, LAUNCHER_NAME), LAUNCHER_NAME); + const bootstrap = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, BOOTSTRAP_NAME), BOOTSTRAP_NAME); + const heldManifest = await openCanonicalRegular(trustedRoot, manifestPath, MANIFEST_NAME); + try { + const bytes = await executable.handle.readFile(); + const launcherBytes = await launcher.handle.readFile(); + const bootstrapBytes = await bootstrap.handle.readFile(); + inspectAnyCpuPe(bytes); + const manifest = parseManifest(await heldManifest.handle.readFile()); + try { inspectWindowsNativeLauncherPe(launcherBytes, manifest.launcher.architecture); } catch { fail(); } + try { inspectWindowsNativeLauncherPe(bootstrapBytes, manifest.bootstrap.architecture); } catch { fail(); } + const production = env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1'; + const publisher = production ? String(env.PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY || '') : null; + const signerPins = production ? String(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS || '').split(',') : []; + const signerCertificateSha256 = production + ? String(env.PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256 || '') : null; + const signerSpkiSha256 = production ? String(env.PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256 || '') : null; + if (production && (!publisher || signerPins.length === 0 + || signerPins.some(pin => !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(signerPins).size !== signerPins.length + || signerPins.join(',') !== [...signerPins].sort().join(',') + || !/^[a-f0-9]{64}$/.test(signerCertificateSha256) + || !/^[a-f0-9]{64}$/.test(signerSpkiSha256) + || !signerPins.some(pin => pin === `certificate-sha256:${signerCertificateSha256}` + || pin === `spki-sha256:${signerSpkiSha256}`))) fail(); + const refreshed = Buffer.from(`${JSON.stringify({ + ...manifest, + size: bytes.length, + sha256: digest(bytes), + trust: production ? 'production-signed' : 'unsigned-validation', + publisher, + signerPins, + signerCertificateSha256, + signerSpkiSha256, + launcher: { + ...manifest.launcher, + size: launcherBytes.length, + sha256: digest(launcherBytes), + trust: production ? 'production-signed' : 'unsigned-validation', + publisher, + signerPins, + signerCertificateSha256, + signerSpkiSha256, + }, + bootstrap: { + ...manifest.bootstrap, + size: bootstrapBytes.length, + sha256: digest(bootstrapBytes), + trust: production ? 'production-signed' : 'unsigned-validation', + publisher, + signerPins, + signerCertificateSha256, + signerSpkiSha256, + }, + })}\n`, 'utf8'); + const temporary = `${manifestPath}.${process.pid}.tmp`; + const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + try { await handle.writeFile(refreshed); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, manifestPath); + } finally { + await executable.handle.close(); + await launcher.handle.close(); + await bootstrap.handle.close(); + await heldManifest.handle.close(); + } +}; + +export const inspectPackagedWindowsAuthority = async (executablePath, manifestPath) => { + if (dirname(executablePath) !== dirname(manifestPath)) fail(); + const trustedRoot = dirname(executablePath); + const executable = await openCanonicalRegular(trustedRoot, executablePath, EXECUTABLE_NAME); + const launcher = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, LAUNCHER_NAME), LAUNCHER_NAME); + const bootstrap = await openCanonicalRegular(trustedRoot, resolve(trustedRoot, BOOTSTRAP_NAME), BOOTSTRAP_NAME); + const heldManifest = await openCanonicalRegular(trustedRoot, manifestPath, MANIFEST_NAME); + try { + const manifest = parseManifest(await heldManifest.handle.readFile()); + const bytes = await executable.handle.readFile(); + const launcherBytes = await launcher.handle.readFile(); + const bootstrapBytes = await bootstrap.handle.readFile(); + inspectAnyCpuPe(bytes); + try { inspectWindowsNativeLauncherPe(launcherBytes, manifest.launcher.architecture); } catch { fail(); } + try { inspectWindowsNativeLauncherPe(bootstrapBytes, manifest.bootstrap.architecture); } catch { fail(); } + if (bytes.length !== manifest.size || digest(bytes) !== manifest.sha256 + || launcherBytes.length !== manifest.launcher.size || digest(launcherBytes) !== manifest.launcher.sha256 + || bootstrapBytes.length !== manifest.bootstrap.size || digest(bootstrapBytes) !== manifest.bootstrap.sha256) fail(); + const after = await executable.handle.stat({ bigint: true }); + const manifestAfter = await heldManifest.handle.stat({ bigint: true }); + const launcherAfter = await launcher.handle.stat({ bigint: true }); + const bootstrapAfter = await bootstrap.handle.stat({ bigint: true }); + if (after.dev !== executable.stats.dev || after.ino !== executable.stats.ino || after.size !== executable.stats.size + || manifestAfter.dev !== heldManifest.stats.dev || manifestAfter.ino !== heldManifest.stats.ino + || manifestAfter.size !== heldManifest.stats.size + || launcherAfter.dev !== launcher.stats.dev || launcherAfter.ino !== launcher.stats.ino + || launcherAfter.size !== launcher.stats.size + || bootstrapAfter.dev !== bootstrap.stats.dev || bootstrapAfter.ino !== bootstrap.stats.ino + || bootstrapAfter.size !== bootstrap.stats.size) fail(); + return manifest; + } finally { + await executable.handle.close(); + await launcher.handle.close(); + await bootstrap.handle.close(); + await heldManifest.handle.close(); + } +}; + +const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invoked) { + const refresh = process.argv[2] === '--refresh'; + const [executablePath, manifestPath] = refresh ? process.argv.slice(3) : process.argv.slice(2); + if (!executablePath || !manifestPath || (refresh ? process.argv.length !== 5 : process.argv.length !== 4)) fail(); + await (refresh + ? refreshPackagedWindowsAuthorityManifest(executablePath, manifestPath) + : inspectPackagedWindowsAuthority(executablePath, manifestPath)); + process.stdout.write(`Packaged Windows authority helper ${refresh ? 'manifest refreshed' : 'verified'}\n`); +} diff --git a/apps/desktop/scripts/make-dmg.mjs b/apps/desktop/scripts/make-dmg.mjs new file mode 100644 index 000000000..17abea7e2 --- /dev/null +++ b/apps/desktop/scripts/make-dmg.mjs @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { access, cp, mkdir, mkdtemp, open, readFile, rename, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { basename, join, resolve } from 'node:path'; + +const execFileAsync = promisify(execFile); +const HDIUTIL = '/usr/bin/hdiutil'; +if (process.platform !== 'darwin') throw new Error('DMG artifacts must be built on a native macOS host'); + +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const version = process.env.PROPR_DESKTOP_VERSION?.trim() || packageJson.version; +const archArgument = process.argv.find(argument => argument.startsWith('--arch=')); +const arch = archArgument?.slice('--arch='.length) || process.arch; +if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + throw new Error(`Invalid desktop release version: ${version}`); +} +if (arch !== 'x64' && arch !== 'arm64') throw new Error(`Unsupported macOS architecture: ${arch}`); + +const appPath = resolve('out', `propr-desktop-darwin-${arch}`, 'propr-desktop.app'); +const outputDirectory = resolve('out', 'make', 'dmg', arch); +const outputPath = resolve(outputDirectory, `ProPR-Desktop-${version}-macos-${arch}.dmg`); +await access(appPath); +await mkdir(outputDirectory, { recursive: true }); +let created = false; +for (let attempt = 0; attempt < 2 && !created; attempt += 1) { + const stagingDirectory = await mkdtemp(join(tmpdir(), 'propr-dmg-layout-')); + const temporaryOutput = join(outputDirectory, `.propr-dmg-${randomUUID()}.partial.dmg`); + try { + await cp(appPath, join(stagingDirectory, basename(appPath)), { recursive: true, verbatimSymlinks: true }); + await symlink('/Applications', join(stagingDirectory, 'Applications')); + await execFileAsync(HDIUTIL, [ + 'create', + '-volname', 'ProPR Desktop', + '-srcfolder', stagingDirectory, + '-format', 'UDZO', + temporaryOutput, + ]); + await rename(temporaryOutput, outputPath); + // Publish only after both the image and containing directory have reached + // stable storage, and close every maker handle before a verifier opens it. + const image = await open(outputPath, 'r'); + try { await image.sync(); } finally { await image.close(); } + const directory = await open(outputDirectory, 'r'); + try { await directory.sync(); } finally { await directory.close(); } + created = true; + } catch (error) { + const resourceBusy = typeof error === 'object' && error !== null + && typeof error.stderr === 'string' + && /^hdiutil: create failed - Resource busy\s*$/.test(error.stderr); + if (!resourceBusy || attempt !== 0) { + throw new Error(resourceBusy + ? 'Native DMG creation repeatedly reported resource busy' + : 'Native DMG creation failed'); + } + console.warn('Native DMG creation reported one transient resource-busy result; retrying once'); + } finally { + try { await rm(temporaryOutput, { force: true }); } finally { + await rm(stagingDirectory, { recursive: true, force: true }); + } + } +} +console.log(outputPath); diff --git a/apps/desktop/scripts/probe-packaged-windows-authority.ts b/apps/desktop/scripts/probe-packaged-windows-authority.ts new file mode 100644 index 000000000..3019f62a8 --- /dev/null +++ b/apps/desktop/scripts/probe-packaged-windows-authority.ts @@ -0,0 +1,10 @@ +import { isAbsolute, resolve } from 'node:path'; +import { probePackagedWindowsAuthorityHelper } from '../src/windows-update-authority'; + +const [directory] = process.argv.slice(2); +if (!directory || process.argv.length !== 3 || !isAbsolute(directory)) { + throw new Error('Packaged Windows authority probe requires one absolute helper directory'); +} +const stage = await probePackagedWindowsAuthorityHelper(resolve(directory)); +if (stage !== 'READY') throw new Error(`Packaged Windows authority helper failed at ${stage}`); +process.stdout.write('Packaged Windows authority helper reached READY\n'); diff --git a/apps/desktop/scripts/release-architecture.mjs b/apps/desktop/scripts/release-architecture.mjs new file mode 100644 index 000000000..308019a0c --- /dev/null +++ b/apps/desktop/scripts/release-architecture.mjs @@ -0,0 +1,1315 @@ +import { execFile as execFileCallback, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, open, mkdtemp, readdir, readlink, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { inflateRawSync } from 'node:zlib'; + +const execFile = promisify(execFileCallback); +const desktopRoot = fileURLToPath(new URL('..', import.meta.url)); +const sevenZip = process.platform === 'win32' + ? join(desktopRoot, '..', '..', 'node_modules', 'electron-winstaller', 'vendor', + process.arch === 'arm64' ? '7z-arm64.exe' : '7z-x64.exe') + : '7z'; +const heldDmgArtifacts = new WeakMap(); +const HDIUTIL = '/usr/bin/hdiutil'; +const EXECUTABLE_NAME = 'propr-desktop'; +const WINDOWS_AUTHORITY_EXECUTABLE = 'lib/net45/resources/windows-authority/propr-windows-authority.exe'; +const WINDOWS_AUTHORITY_MANIFEST = 'lib/net45/resources/windows-authority/propr-windows-authority.manifest.json'; +const WINDOWS_AUTHORITY_LAUNCHER = 'lib/net45/resources/windows-authority/propr-windows-launcher.node'; +const WINDOWS_AUTHORITY_BOOTSTRAP = 'lib/net45/resources/windows-authority/propr-windows-bootstrap.node'; +const DMG_INSTALL_LINK = 'Applications'; +const DMG_HELPER_BUNDLES = new Set([ + `${EXECUTABLE_NAME} Helper.app`, + `${EXECUTABLE_NAME} Helper (GPU).app`, + `${EXECUTABLE_NAME} Helper (Plugin).app`, + `${EXECUTABLE_NAME} Helper (Renderer).app`, +]); +const DMG_HELPER_EXECUTABLES = new Set([...DMG_HELPER_BUNDLES] + .map(name => name.slice(0, -'.app'.length).toLocaleLowerCase('en-US'))); +export const NATIVE_DMG_VALIDATOR = Object.freeze({ + schemaVersion: 1, + tool: 'propr-desktop-release-architecture', + toolVersion: '1.0.0', + nativePlatform: 'darwin', + mountMethod: 'hdiutil-attach-readonly', +}); + +export const createHeldDmgArtifact = (handle, description, privatePath) => { + if (!handle || !Number.isInteger(handle.fd) || handle.fd < 0) { + throw new Error('Held DMG artifact requires an open read-only file handle'); + } + if (privatePath !== undefined && (typeof privatePath !== 'string' || !isAbsolute(privatePath))) { + throw new Error('Held DMG private pathname must be absolute'); + } + const capability = Object.freeze({ description }); + heldDmgArtifacts.set(capability, { handle, description, privatePath }); + return capability; +}; + +const requireHeldDmgArtifact = capability => { + const held = heldDmgArtifacts.get(capability); + if (!held || held.handle.fd < 0) { + throw new Error('DMG inspection requires a live held exact-artifact capability'); + } + return held; +}; + +export const readHeldDmgArtifactBytes = async capability => { + const { handle } = requireHeldDmgArtifact(capability); + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile() || stats.size < 0n || stats.size > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('Held DMG artifact is not a safe regular file'); + } + const bytes = Buffer.alloc(Number(stats.size)); + let offset = 0; + while (offset < bytes.length) { + const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset); + if (bytesRead === 0) throw new Error('Held DMG artifact changed while it was read'); + offset += bytesRead; + } + return bytes; +}; +const LINUX_APP_DIRECTORY = join('usr', 'lib', EXECUTABLE_NAME); +const LINUX_PAYLOAD = join(LINUX_APP_DIRECTORY, EXECUTABLE_NAME); +const LINUX_LAUNCHER = join('usr', 'bin', EXECUTABLE_NAME); +const LINUX_DOC_DIRECTORY = join('usr', 'share', 'doc', EXECUTABLE_NAME); +const DEB_LINTIAN_OVERRIDE = join('usr', 'share', 'lintian', 'overrides', EXECUTABLE_NAME); +const MAX_EXECUTABLE_BYTES = 512 * 1024 * 1024; +const MAX_ZIP_DIRECTORY_BYTES = 64 * 1024 * 1024; +const MAX_ZIP_ENTRY_METADATA_BYTES = 1024 * 1024; +const MAX_ZIP_ENTRIES = 100_000; +const MAX_ZIP_SYMLINK_BYTES = 1024; +const MAX_ZIP_SYMLINKS = 32; +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }); +const EXPECTED_PACKAGE_ARCHITECTURE = { + deb: { x64: 'amd64', arm64: 'arm64' }, + rpm: { x64: 'x86_64', arm64: 'aarch64' }, +}; + +const readPrefix = async (path, length = 4096) => { + const handle = await open(path, 'r'); + try { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, 0); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } +}; + +const architectureForCpuType = cpuType => { + if (cpuType === 0x01000007) return 'x64'; + if (cpuType === 0x0100000c) return 'arm64'; + return `unknown-${cpuType.toString(16)}`; +}; + +export const inspectExecutableBytes = bytes => { + if (bytes.length >= 20 && bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + const littleEndian = bytes[5] === 1; + if (!littleEndian && bytes[5] !== 2) throw new Error('ELF executable has an invalid byte order'); + const machine = littleEndian ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); + const architecture = machine === 62 ? 'x64' : machine === 183 ? 'arm64' : `unknown-${machine}`; + return { format: 'elf', architectures: [architecture] }; + } + + if (bytes.length >= 64 && bytes[0] === 0x4d && bytes[1] === 0x5a) { + const peOffset = bytes.readUInt32LE(0x3c); + if (peOffset + 6 > bytes.length || bytes.readUInt32LE(peOffset) !== 0x00004550) { + throw new Error('PE executable header is missing or truncated'); + } + const machine = bytes.readUInt16LE(peOffset + 4); + const architecture = machine === 0x014c + ? 'x86' + : machine === 0x8664 + ? 'x64' + : machine === 0xaa64 + ? 'arm64' + : `unknown-${machine.toString(16)}`; + return { format: 'pe', architectures: [architecture] }; + } + + if (bytes.length >= 8) { + const magic = bytes.readUInt32BE(0); + const thin = new Map([ + [0xfeedface, false], [0xfeedfacf, false], + [0xcefaedfe, true], [0xcffaedfe, true], + ]); + if (thin.has(magic)) { + const cpuType = thin.get(magic) ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4); + return { format: 'mach-o', architectures: [architectureForCpuType(cpuType)] }; + } + const fat = new Map([ + [0xcafebabe, { little: false, width: 20 }], + [0xcafebabf, { little: false, width: 24 }], + [0xbebafeca, { little: true, width: 20 }], + [0xbfbafeca, { little: true, width: 24 }], + ]); + const fatFormat = fat.get(magic); + if (fatFormat) { + const read32 = fatFormat.little ? Buffer.prototype.readUInt32LE : Buffer.prototype.readUInt32BE; + const count = read32.call(bytes, 4); + if (!Number.isSafeInteger(count) || count < 1 || count > 32 || 8 + count * fatFormat.width > bytes.length) { + throw new Error('Mach-O universal header is invalid or truncated'); + } + const architectures = []; + for (let index = 0; index < count; index += 1) { + architectures.push(architectureForCpuType(read32.call(bytes, 8 + index * fatFormat.width))); + } + return { format: 'mach-o', architectures: [...new Set(architectures)].sort() }; + } + } + throw new Error('Packaged executable is not a recognized ELF, PE, or Mach-O binary'); +}; + +const assertExecutableArchitecture = (inspection, platform, arch, artifact) => { + const expectedFormat = platform === 'linux' ? 'elf' : platform === 'win32' ? 'pe' : 'mach-o'; + if (inspection.format !== expectedFormat || inspection.architectures.length !== 1 || inspection.architectures[0] !== arch) { + throw new Error( + `${artifact} executable architecture mismatch: expected ${expectedFormat}/${arch}, found ${inspection.format}/${inspection.architectures.join(',')}`, + ); + } +}; + +const assertSupportedSquirrelBootstrap = (inspection, artifact) => { + const architecture = inspection.architectures[0]; + if (inspection.format !== 'pe' || inspection.architectures.length !== 1 + || !['x86', 'x64', 'arm64'].includes(architecture)) { + throw new Error(`${artifact} is not a supported x86, x64, or arm64 Squirrel PE bootstrapper`); + } +}; + +const inspectMachineMsi = async (path, platform, arch) => { + if (platform !== 'win32') throw new Error(`${path} machine installer is only valid for Windows targets`); + const header = await readPrefix(path); + if (header.length < 512 || header.subarray(0, 8).toString('hex') !== 'd0cf11e0a1b11ae1') { + throw new Error(`${path} is not a compound-file Windows Installer package`); + } + const extraction = await mkdtemp(join(tmpdir(), 'propr-msi-inspect-')); + try { + await execFile(sevenZip, ['x', '-y', '-bso0', '-bsp0', `-o${extraction}`, path], { + timeout: 120_000, + maxBuffer: 64 * 1024, + }); + const files = []; + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + const stats = await lstat(entryPath); + if (stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile())) { + throw new Error(`${path} machine installer extracts a link or special file`); + } + if (stats.isDirectory()) await visit(entryPath); + else if (files.push(entryPath) > 10_000) throw new Error(`${path} machine installer has too many files`); + } + }; + await visit(extraction); + const named = name => files.filter(file => basename(file).toLocaleLowerCase('en-US') === name); + const applications = named('propr-desktop.exe'); + if (applications.length !== 1 + || named('propr-windows-authority.exe').length !== 1 + || named('propr-windows-authority.manifest.json').length !== 1 + || named('propr-windows-launcher.node').length !== 1 + || named('propr-windows-bootstrap.node').length !== 1) { + throw new Error(`${path} machine installer has an incomplete or ambiguous protected application layout`); + } + const executable = inspectExecutableBytes(await readPrefix(applications[0])); + assertExecutableArchitecture(executable, platform, arch, path); + return { format: 'windows-machine-msi', scope: 'per-machine', executable }; + } finally { + await rm(extraction, { recursive: true, force: true }); + } +}; + +const pathInside = (root, path) => { + const child = relative(root, path); + return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`)); +}; + +const displayPackagePath = (root, path) => relative(root, path).split(sep).join('/'); + +const describeFileType = stats => { + if (stats.isFile()) return 'regular file'; + if (stats.isDirectory()) return 'directory'; + if (stats.isSymbolicLink()) return 'symbolic link'; + return 'special file'; +}; + +const readPackageEntry = async (path, description) => { + try { + return await lstat(path); + } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`Linux package is missing ${description}`); + throw error; + } +}; + +const collectSameNameEntries = async root => { + const entries = []; + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const stats = await lstat(path); + if (entry.name.toLowerCase() === EXECUTABLE_NAME) entries.push({ path, stats }); + if (stats.isDirectory()) await visit(path); + } + }; + await visit(root); + return entries; +}; + +const resolvePackageSymlink = async (root, start) => { + const rootPath = resolve(root); + const startPath = resolve(start); + if (!pathInside(rootPath, startPath)) throw new Error('Linux package launcher escapes the extraction root'); + let components = relative(rootPath, startPath).split(sep).filter(Boolean); + const visited = new Set(); + + while (components.length > 0) { + let current = rootPath; + let followedLink = false; + for (let index = 0; index < components.length; index += 1) { + current = join(current, components[index]); + const stats = await readPackageEntry(current, `launcher target ${displayPackagePath(rootPath, current)}`); + if (stats.isSymbolicLink()) { + if (visited.has(current)) throw new Error('Linux package launcher contains a symbolic-link cycle'); + visited.add(current); + if (visited.size > 64) throw new Error('Linux package launcher has too many symbolic links'); + const target = await readlink(current); + if (isAbsolute(target)) throw new Error('Linux package launcher uses an absolute symbolic link'); + const resolvedTarget = resolve(dirname(current), target); + if (!pathInside(rootPath, resolvedTarget)) throw new Error('Linux package launcher escapes the extraction root'); + components = [ + ...relative(rootPath, resolvedTarget).split(sep).filter(Boolean), + ...components.slice(index + 1), + ]; + followedLink = true; + break; + } + if (index < components.length - 1 && !stats.isDirectory()) { + throw new Error(`Linux package launcher traverses non-directory ${displayPackagePath(rootPath, current)}`); + } + if (index === components.length - 1) return { path: current, stats }; + } + if (!followedLink) break; + } + throw new Error('Linux package launcher target is invalid'); +}; + +export const inspectLinuxPackageLayout = async ({ root, packageFormat, platform, arch, artifact }) => { + if (platform !== 'linux') throw new Error(`${artifact} Linux package is only valid for Linux targets`); + if (!['deb', 'rpm'].includes(packageFormat)) throw new Error(`${artifact} Linux package format is invalid`); + const rootPath = resolve(root); + const appDirectory = join(rootPath, LINUX_APP_DIRECTORY); + const payload = join(rootPath, LINUX_PAYLOAD); + const launcher = join(rootPath, LINUX_LAUNCHER); + + for (const [path, description] of [ + [join(rootPath, 'usr'), 'usr directory'], + [join(rootPath, 'usr', 'lib'), 'usr/lib directory'], + [appDirectory, `${LINUX_APP_DIRECTORY.split(sep).join('/')} directory`], + [join(rootPath, 'usr', 'bin'), 'usr/bin directory'], + ]) { + const stats = await readPackageEntry(path, description); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error(`Linux package ${description} must be a real directory, found ${describeFileType(stats)}`); + } + } + + const sameNameEntries = await collectSameNameEntries(rootPath); + const requiredEntries = new Map([ + [appDirectory, 'directory'], + [payload, 'regular file'], + [launcher, 'symbolic link'], + ]); + const allowedEntries = new Map([ + ...requiredEntries, + [join(rootPath, LINUX_DOC_DIRECTORY), 'directory'], + ...(packageFormat === 'deb' ? [[join(rootPath, DEB_LINTIAN_OVERRIDE), 'regular file']] : []), + ]); + const unexpected = sameNameEntries.filter(({ path, stats }) => { + const expectedType = allowedEntries.get(path); + return !expectedType || describeFileType(stats) !== expectedType; + }); + const missing = [...requiredEntries].filter(([path, expectedType]) => ( + !sameNameEntries.some(entry => entry.path === path && describeFileType(entry.stats) === expectedType) + )); + if (missing.length > 0 || unexpected.length > 0) { + const found = sameNameEntries + .map(({ path, stats }) => `${displayPackagePath(rootPath, path)} (${describeFileType(stats)})`) + .sort() + .join(', ') || 'none'; + throw new Error(`Linux package must contain only the canonical payload and launcher layout; found ${found}`); + } + + const payloadStats = await readPackageEntry(payload, `regular payload ${LINUX_PAYLOAD.split(sep).join('/')}`); + if (!payloadStats.isFile() || payloadStats.isSymbolicLink()) { + throw new Error(`Linux package payload must be a regular file, found ${describeFileType(payloadStats)}`); + } + const lintianOverride = sameNameEntries.find(entry => entry.path === join(rootPath, DEB_LINTIAN_OVERRIDE)); + if (lintianOverride) { + const prefix = await readPrefix(lintianOverride.path, 4); + if (lintianOverride.stats.size > 64 * 1024 + || prefix.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + throw new Error('DEB lintian override must not contain an extra ELF payload'); + } + } + const resolvedLauncher = await resolvePackageSymlink(rootPath, launcher); + if (resolvedLauncher.path !== payload || !resolvedLauncher.stats.isFile()) { + throw new Error(`Linux package launcher must resolve to ${LINUX_PAYLOAD.split(sep).join('/')}`); + } + const inspection = inspectExecutableBytes(await readPrefix(payload)); + assertExecutableArchitecture(inspection, platform, arch, artifact); + return inspection; +}; + +const crcTable = Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + return crc >>> 0; +}); + +const crc32 = bytes => { + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +}; + +const readExact = async (handle, length, position, label) => { + const bytes = Buffer.alloc(length); + const { bytesRead } = await handle.read(bytes, 0, length, position); + if (bytesRead !== length) throw new Error(`${label} is truncated`); + return bytes; +}; + +const validateExtraFields = (bytes, label) => { + for (let offset = 0; offset < bytes.length;) { + if (offset + 4 > bytes.length) throw new Error(`${label} contains truncated ZIP extra metadata`); + const id = bytes.readUInt16LE(offset); + const length = bytes.readUInt16LE(offset + 2); + if (offset + 4 + length > bytes.length) throw new Error(`${label} contains truncated ZIP extra metadata`); + if (id === 0x0001 || id === 0x9901) throw new Error(`${label} uses unsupported ZIP64 or encrypted metadata`); + offset += 4 + length; + } +}; + +const decodeZipName = (bytes, flags) => { + let name; + try { + if ((flags & 0x0800) !== 0) name = UTF8_DECODER.decode(bytes); + else { + if (bytes.some(byte => byte > 0x7f)) throw new Error('legacy non-ASCII ZIP names are unsupported'); + name = bytes.toString('ascii'); + } + } catch (error) { + throw new Error(`ZIP entry name cannot be decoded strictly: ${error.message}`); + } + if (!name || name.includes('\0') || name.includes('\\') || name.normalize('NFC') !== name + || name.startsWith('/') || name.startsWith('//') || /^[A-Za-z]:/.test(name)) { + throw new Error(`ZIP entry has an unsafe name: ${JSON.stringify(name)}`); + } + const directory = name.endsWith('/'); + const path = directory ? name.slice(0, -1) : name; + if (!path || path.startsWith('/') || path.endsWith('/') || posix.normalize(path) !== path + || path.split('/').some(component => !component || component === '.' || component === '..')) { + throw new Error(`ZIP entry has a non-normalized relative POSIX path: ${JSON.stringify(name)}`); + } + return { name, path, directory }; +}; + +const archiveExecutablePath = (kind, platform, arch) => { + if (kind === 'nupkg' && platform === 'win32') return `lib/net45/${EXECUTABLE_NAME}.exe`; + if (kind === 'zip' && platform === 'linux') return `${EXECUTABLE_NAME}-linux-${arch}/${EXECUTABLE_NAME}`; + if (kind === 'zip' && platform === 'darwin') return `${EXECUTABLE_NAME}.app/Contents/MacOS/${EXECUTABLE_NAME}`; + throw new Error(`${kind} does not have a canonical executable path for ${platform}-${arch}`); +}; + +const darwinFrameworkRoot = entryPath => { + const components = entryPath.split('/'); + if (components.length < 5 + || components[0] !== `${EXECUTABLE_NAME}.app` + || components[1] !== 'Contents' + || components[2] !== 'Frameworks' + || !components[3].endsWith('.framework') + || components[3] === '.framework' + || components.slice(4).some(component => component.toLocaleLowerCase('en-US').endsWith('.app')) + || DMG_HELPER_EXECUTABLES.has(components.at(-1).toLocaleLowerCase('en-US'))) return undefined; + return components.slice(0, 4).join('/'); +}; + +const decodeZipSymlinkTarget = entry => { + if (entry.bytes.length === 0 || entry.bytes.length > MAX_ZIP_SYMLINK_BYTES) { + throw new Error(`ZIP symbolic link ${entry.name} has an empty or oversized payload`); + } + let target; + try { + target = UTF8_DECODER.decode(entry.bytes); + } catch (error) { + throw new Error(`ZIP symbolic link ${entry.name} target cannot be decoded strictly: ${error.message}`); + } + if (target.includes('\0') || target.includes('\\') || target.normalize('NFC') !== target + || target.startsWith('/') || target.startsWith('//') || /^[A-Za-z]:/.test(target) + || posix.normalize(target) !== target + || target.split('/').some(component => !component || component === '.' || component === '..')) { + throw new Error(`ZIP symbolic link ${entry.name} has an unsafe relative target`); + } + return target; +}; + +const decodeDmgSymlinkTarget = (bytes, entryPath) => { + if (bytes.length === 0 || bytes.length > MAX_ZIP_SYMLINK_BYTES) { + throw new Error(`DMG framework symbolic link ${entryPath} has an empty or oversized target`); + } + let target; + try { + target = UTF8_DECODER.decode(bytes); + } catch (error) { + throw new Error(`DMG framework symbolic link ${entryPath} target cannot be decoded strictly: ${error.message}`); + } + if (target.includes('\0') || target.includes('\\') || target.normalize('NFC') !== target + || target.startsWith('/') || target.startsWith('//') || /^[A-Za-z]:/.test(target) + || posix.normalize(target) !== target + || target.split('/').some(component => !component || component === '.' || component === '..')) { + throw new Error(`DMG framework symbolic link ${entryPath} has an unsafe relative target`); + } + return target; +}; + +const validateDarwinFrameworkSymlinks = entries => { + const symlinks = entries.filter(entry => entry.symbolicLink); + if (symlinks.length > MAX_ZIP_SYMLINKS) throw new Error('ZIP contains too many symbolic links'); + const entriesByPath = new Map(entries.map(entry => [entry.path, entry])); + const entryPaths = [...entriesByPath.keys()]; + for (const entry of symlinks) entry.target = decodeZipSymlinkTarget(entry); + + const pathExistsAsDirectory = candidate => entryPaths.some(entryPath => entryPath.startsWith(`${candidate}/`)); + for (const link of symlinks) { + const frameworkRoot = link.frameworkRoot; + let components = link.path.split('/'); + const visited = new Set(); + let index = 0; + while (index < components.length) { + const candidate = components.slice(0, index + 1).join('/'); + const entry = entriesByPath.get(candidate); + if (entry?.symbolicLink) { + if (visited.has(candidate)) throw new Error(`ZIP symbolic link ${link.name} contains a cycle`); + visited.add(candidate); + if (visited.size > MAX_ZIP_SYMLINKS) throw new Error(`ZIP symbolic link ${link.name} chain is too long`); + const resolvedTarget = posix.normalize(posix.join(posix.dirname(candidate), entry.target)); + if (resolvedTarget !== frameworkRoot && !resolvedTarget.startsWith(`${frameworkRoot}/`)) { + throw new Error(`ZIP symbolic link ${link.name} escapes its canonical framework`); + } + components = [...resolvedTarget.split('/'), ...components.slice(index + 1)]; + index = 0; + continue; + } + const hasRemainingComponents = index < components.length - 1; + if (!entry && !pathExistsAsDirectory(candidate)) { + throw new Error(`ZIP symbolic link ${link.name} has a missing target ${candidate}`); + } + if (hasRemainingComponents && entry && !entry.directory) { + throw new Error(`ZIP symbolic link ${link.name} traverses non-directory target ${candidate}`); + } + index += 1; + } + const resolved = components.join('/'); + if (resolved !== frameworkRoot && !resolved.startsWith(`${frameworkRoot}/`)) { + throw new Error(`ZIP symbolic link ${link.name} escapes its canonical framework`); + } + } +}; + +const validateDmgFrameworkSymlinks = entries => { + const symlinks = entries.filter(entry => entry.symbolicLink); + if (symlinks.length > MAX_ZIP_SYMLINKS) throw new Error('DMG contains too many symbolic links'); + const entriesByPath = new Map(entries.map(entry => [entry.path, entry])); + + for (const link of symlinks) { + const frameworkRoot = link.frameworkRoot; + let components = link.path.split('/'); + const visited = new Set(); + let index = 0; + while (index < components.length) { + const candidate = components.slice(0, index + 1).join('/'); + const entry = entriesByPath.get(candidate); + if (entry?.symbolicLink) { + if (visited.has(candidate)) throw new Error(`DMG framework symbolic link ${link.path} contains a cycle`); + visited.add(candidate); + if (visited.size > MAX_ZIP_SYMLINKS) throw new Error(`DMG framework symbolic link ${link.path} chain is too long`); + const resolvedTarget = posix.normalize(posix.join(posix.dirname(candidate), entry.target)); + if (resolvedTarget !== frameworkRoot && !resolvedTarget.startsWith(`${frameworkRoot}/`)) { + throw new Error(`DMG framework symbolic link ${link.path} escapes its canonical framework`); + } + components = [...resolvedTarget.split('/'), ...components.slice(index + 1)]; + index = 0; + continue; + } + if (!entry) throw new Error(`DMG framework symbolic link ${link.path} has a missing target ${candidate}`); + if (index < components.length - 1 && !entry.directory) { + throw new Error(`DMG framework symbolic link ${link.path} traverses non-directory target ${candidate}`); + } + index += 1; + } + const resolved = components.join('/'); + if (resolved !== frameworkRoot && !resolved.startsWith(`${frameworkRoot}/`)) { + throw new Error(`DMG framework symbolic link ${link.path} escapes its canonical framework`); + } + } +}; + +const readValidatedZipExecutable = async (path, kind, platform, arch) => { + const handle = await open(path, 'r'); + try { + const { size } = await handle.stat(); + const tailLength = Math.min(size, 65_557); + const tail = await readExact(handle, tailLength, size - tailLength, 'ZIP tail'); + const eocdCandidates = []; + for (let offset = tail.length - 22; offset >= 0; offset -= 1) { + if (tail.readUInt32LE(offset) === 0x06054b50 + && offset + 22 + tail.readUInt16LE(offset + 20) === tail.length) eocdCandidates.push(offset); + } + if (eocdCandidates.length !== 1) throw new Error('ZIP end-of-central-directory record is missing or ambiguous'); + const eocd = eocdCandidates[0]; + if (tail.readUInt16LE(eocd + 20) !== 0) throw new Error('ZIP archive comments create trailing ambiguity'); + if (tail.readUInt16LE(eocd + 4) !== 0 || tail.readUInt16LE(eocd + 6) !== 0) { + throw new Error('Multi-disk ZIP archives are unsupported'); + } + const diskEntries = tail.readUInt16LE(eocd + 8); + const entryCount = tail.readUInt16LE(eocd + 10); + const centralSize = tail.readUInt32LE(eocd + 12); + const centralOffset = tail.readUInt32LE(eocd + 16); + const eocdOffset = size - tailLength + eocd; + if (diskEntries !== entryCount || entryCount > MAX_ZIP_ENTRIES + || entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff + || centralSize > MAX_ZIP_DIRECTORY_BYTES || centralOffset + centralSize !== eocdOffset) { + throw new Error('ZIP central directory is invalid or oversized'); + } + const central = await readExact(handle, centralSize, centralOffset, 'ZIP central directory'); + const entries = []; + for (let offset = 0; offset < central.length;) { + if (offset + 46 > central.length) throw new Error('ZIP central directory entry is truncated'); + if (central.readUInt32LE(offset) !== 0x02014b50) throw new Error('ZIP central directory entry is invalid'); + const flags = central.readUInt16LE(offset + 8); + const method = central.readUInt16LE(offset + 10); + const checksum = central.readUInt32LE(offset + 16); + const compressedSize = central.readUInt32LE(offset + 20); + const uncompressedSize = central.readUInt32LE(offset + 24); + const nameLength = central.readUInt16LE(offset + 28); + const extraLength = central.readUInt16LE(offset + 30); + const commentLength = central.readUInt16LE(offset + 32); + const disk = central.readUInt16LE(offset + 34); + const externalAttributes = central.readUInt32LE(offset + 38); + const localOffset = central.readUInt32LE(offset + 42); + const nextOffset = offset + 46 + nameLength + extraLength + commentLength; + if (nextOffset > central.length || nameLength + extraLength + commentLength > MAX_ZIP_ENTRY_METADATA_BYTES) { + throw new Error('ZIP central directory entry is truncated or has oversized metadata'); + } + if (disk !== 0 || (flags & ~(0x0800 | 0x0008 | 0x0006)) !== 0 || ![0, 8].includes(method) + || (method === 0 && (flags & 0x0006) !== 0) + || compressedSize > MAX_EXECUTABLE_BYTES || uncompressedSize > MAX_EXECUTABLE_BYTES) { + throw new Error('ZIP entry is encrypted, unsupported, or oversized'); + } + const nameBytes = central.subarray(offset + 46, offset + 46 + nameLength); + const decoded = decodeZipName(nameBytes, flags); + const extra = central.subarray(offset + 46 + nameLength, offset + 46 + nameLength + extraLength); + validateExtraFields(extra, `ZIP entry ${decoded.name}`); + const unixType = (externalAttributes >>> 16) & 0xf000; + const symbolicLink = unixType === 0xa000; + const frameworkRoot = symbolicLink && kind === 'zip' && platform === 'darwin' + ? darwinFrameworkRoot(decoded.path) + : undefined; + if (symbolicLink && (!frameworkRoot || decoded.directory)) { + throw new Error(`ZIP entry ${decoded.name} is a symbolic link outside canonical macOS framework internals`); + } + if (unixType && unixType !== 0x4000 && unixType !== 0x8000 && !symbolicLink) { + throw new Error(`ZIP entry ${decoded.name} is a symbolic link or special file`); + } + if ((decoded.directory && unixType === 0x8000) || (!decoded.directory && unixType === 0x4000)) { + throw new Error(`ZIP entry ${decoded.name} has conflicting file and directory metadata`); + } + if (symbolicLink && (compressedSize > MAX_ZIP_SYMLINK_BYTES || uncompressedSize > MAX_ZIP_SYMLINK_BYTES)) { + throw new Error(`ZIP symbolic link ${decoded.name} has an oversized payload`); + } + entries.push({ + ...decoded, + flags, + method, + checksum, + compressedSize, + uncompressedSize, + localOffset, + nameBytes, + symbolicLink, + frameworkRoot, + }); + offset = nextOffset; + } + if (entries.length !== entryCount) throw new Error('ZIP central directory entry count is inconsistent'); + const exactNames = new Set(); + const caseNames = new Set(); + const componentCase = new Map(); + for (const entry of entries) { + if (exactNames.has(entry.path) || caseNames.has(entry.path.toLocaleLowerCase('en-US'))) { + throw new Error(`ZIP contains duplicate or case-colliding entry ${entry.name}`); + } + exactNames.add(entry.path); + caseNames.add(entry.path.toLocaleLowerCase('en-US')); + const components = entry.path.split('/'); + for (let length = 1; length <= components.length; length += 1) { + const prefix = components.slice(0, length).join('/'); + const key = prefix.toLocaleLowerCase('en-US'); + if (componentCase.has(key) && componentCase.get(key) !== prefix) { + throw new Error(`ZIP contains case-colliding path components at ${entry.name}`); + } + componentCase.set(key, prefix); + } + } + for (const entry of entries.filter(candidate => !candidate.directory)) { + const prefix = `${entry.path.toLocaleLowerCase('en-US')}/`; + if (entries.some(candidate => candidate.path.toLocaleLowerCase('en-US').startsWith(prefix))) { + throw new Error(`ZIP contains conflicting file and directory prefix ${entry.path}`); + } + } + + const ranges = []; + let executableBytes; + let authorityExecutableBytes; + let authorityManifestBytes; + let authorityLauncherBytes; + let authorityBootstrapBytes; + const canonicalExecutable = archiveExecutablePath(kind, platform, arch); + const expectedExecutableName = platform === 'win32' ? `${EXECUTABLE_NAME}.exe` : EXECUTABLE_NAME; + const alternateExecutables = entries.filter(entry => !entry.directory + && basename(entry.path).toLocaleLowerCase('en-US') === expectedExecutableName.toLocaleLowerCase('en-US') + && entry.path !== canonicalExecutable); + if (alternateExecutables.length) throw new Error(`ZIP contains an executable outside ${canonicalExecutable}`); + if (kind === 'nupkg' && platform === 'win32') { + const alternateAuthority = entries.filter(entry => !entry.directory + && ['propr-windows-authority.exe', 'propr-windows-authority.manifest.json', 'propr-windows-launcher.node', + 'propr-windows-bootstrap.node'] + .includes(basename(entry.path).toLocaleLowerCase('en-US')) + && ![WINDOWS_AUTHORITY_EXECUTABLE, WINDOWS_AUTHORITY_MANIFEST, WINDOWS_AUTHORITY_LAUNCHER, + WINDOWS_AUTHORITY_BOOTSTRAP].includes(entry.path)); + if (alternateAuthority.length) throw new Error('NUPKG contains an ambiguous Windows authority helper layout'); + } + for (const entry of entries) { + if (entry.localOffset + 30 > centralOffset) throw new Error(`ZIP local header offset is invalid for ${entry.name}`); + const local = await readExact(handle, 30, entry.localOffset, `ZIP local header for ${entry.name}`); + if (local.readUInt32LE(0) !== 0x04034b50) throw new Error(`ZIP local entry header is invalid for ${entry.name}`); + const localFlags = local.readUInt16LE(6); + const localMethod = local.readUInt16LE(8); + const localChecksum = local.readUInt32LE(14); + const localCompressedSize = local.readUInt32LE(18); + const localUncompressedSize = local.readUInt32LE(22); + const localNameLength = local.readUInt16LE(26); + const localExtraLength = local.readUInt16LE(28); + if (localNameLength + localExtraLength > MAX_ZIP_ENTRY_METADATA_BYTES) { + throw new Error(`ZIP local entry metadata is oversized for ${entry.name}`); + } + const localMetadata = await readExact( + handle, + localNameLength + localExtraLength, + entry.localOffset + 30, + `ZIP local metadata for ${entry.name}`, + ); + const localNameBytes = localMetadata.subarray(0, localNameLength); + const localName = decodeZipName(localNameBytes, localFlags); + validateExtraFields(localMetadata.subarray(localNameLength), `ZIP local entry ${entry.name}`); + if (localFlags !== entry.flags || localMethod !== entry.method + || !localNameBytes.equals(entry.nameBytes) || localName.name !== entry.name) { + throw new Error(`ZIP central and local entry metadata disagree for ${entry.name}`); + } + const dataOffset = entry.localOffset + 30 + localNameLength + localExtraLength; + const dataEnd = dataOffset + entry.compressedSize; + if (dataEnd > centralOffset) throw new Error(`ZIP entry exceeds archive bounds for ${entry.name}`); + const compressed = await readExact(handle, entry.compressedSize, dataOffset, `ZIP entry data for ${entry.name}`); + let bytes; + try { + bytes = entry.method === 0 + ? compressed + : inflateRawSync(compressed, { maxOutputLength: MAX_EXECUTABLE_BYTES }); + } catch { + throw new Error(`ZIP entry compression is invalid for ${entry.name}`); + } + if (bytes.length !== entry.uncompressedSize || crc32(bytes) !== entry.checksum) { + throw new Error(`ZIP entry size or CRC is invalid for ${entry.name}`); + } + let recordEnd = dataEnd; + if ((entry.flags & 0x0008) !== 0) { + const prefix = await readExact(handle, 4, recordEnd, `ZIP data descriptor for ${entry.name}`); + const hasSignature = prefix.readUInt32LE(0) === 0x08074b50; + const descriptor = await readExact(handle, hasSignature ? 16 : 12, recordEnd, `ZIP data descriptor for ${entry.name}`); + const base = hasSignature ? 4 : 0; + if (descriptor.readUInt32LE(base) !== entry.checksum + || descriptor.readUInt32LE(base + 4) !== entry.compressedSize + || descriptor.readUInt32LE(base + 8) !== entry.uncompressedSize + || ![0, entry.checksum].includes(localChecksum) + || ![0, entry.compressedSize].includes(localCompressedSize) + || ![0, entry.uncompressedSize].includes(localUncompressedSize)) { + throw new Error(`ZIP central, local, and descriptor sizes or CRC disagree for ${entry.name}`); + } + recordEnd += descriptor.length; + } else if (localChecksum !== entry.checksum || localCompressedSize !== entry.compressedSize + || localUncompressedSize !== entry.uncompressedSize) { + throw new Error(`ZIP central and local sizes or CRC disagree for ${entry.name}`); + } + ranges.push({ start: entry.localOffset, end: recordEnd, name: entry.name }); + if (entry.symbolicLink) entry.bytes = bytes; + if (entry.path === canonicalExecutable) executableBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_EXECUTABLE) authorityExecutableBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_MANIFEST) authorityManifestBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_LAUNCHER) authorityLauncherBytes = bytes; + if (entry.path === WINDOWS_AUTHORITY_BOOTSTRAP) authorityBootstrapBytes = bytes; + } + ranges.sort((left, right) => left.start - right.start); + let expectedOffset = 0; + for (const range of ranges) { + if (range.start !== expectedOffset || range.end <= range.start || range.end > centralOffset) { + throw new Error(`ZIP entries overlap or contain unclaimed data near ${range.name}`); + } + expectedOffset = range.end; + } + if (expectedOffset !== centralOffset) throw new Error('ZIP contains unclaimed data before its central directory'); + validateDarwinFrameworkSymlinks(entries); + if (!executableBytes) throw new Error(`ZIP is missing canonical executable ${canonicalExecutable}`); + if (kind === 'nupkg' && platform === 'win32') { + if (!authorityExecutableBytes || !authorityManifestBytes || !authorityLauncherBytes || !authorityBootstrapBytes + || authorityManifestBytes.length > 16 * 1024 + || authorityManifestBytes.at(-1) !== 0x0a) throw new Error('NUPKG is missing its exact Windows authority helper binding'); + let authorityManifest; + try { authorityManifest = JSON.parse(UTF8_DECODER.decode(authorityManifestBytes.subarray(0, -1))); } + catch { throw new Error('NUPKG Windows authority manifest is not strict UTF-8 JSON'); } + let launcherInspection, bootstrapInspection; + try { + launcherInspection = inspectExecutableBytes(authorityLauncherBytes); + bootstrapInspection = inspectExecutableBytes(authorityBootstrapBytes); + } + catch { throw new Error('NUPKG Windows native launcher is not a valid PE image'); } + const packagedApplicationInspection = inspectExecutableBytes(executableBytes); + const packagedArchitecture = packagedApplicationInspection.architectures.length === 1 + ? packagedApplicationInspection.architectures[0] : ''; + const expectedKeys = ['architecture', 'bootstrap', 'clr', 'compiler', 'format', 'launcher', 'machine', 'name', 'protocol', 'publisher', + 'schemaVersion', 'sha256', 'signerCertificateSha256', 'signerPins', 'signerSpkiSha256', 'size', + 'sourceSha256', 'trust']; + if (!authorityManifest || typeof authorityManifest !== 'object' || Array.isArray(authorityManifest) + || JSON.stringify(Object.keys(authorityManifest).sort()) !== JSON.stringify(expectedKeys) + || authorityManifest.schemaVersion !== 1 || authorityManifest.name !== 'propr-windows-authority.exe' + || authorityManifest.format !== 'PE32' || authorityManifest.architecture !== 'anycpu' + || authorityManifest.machine !== 'I386' || authorityManifest.clr !== true + || authorityManifest.protocol !== 'propr-windows-authority-v1' + || !authorityManifest.compiler || typeof authorityManifest.compiler !== 'object' + || Array.isArray(authorityManifest.compiler) + || JSON.stringify(Object.keys(authorityManifest.compiler).sort()) !== JSON.stringify([ + 'fileId128', 'framework', 'inputs', 'kind', 'signerCertificateSha256', 'signerRootSpkiSha256', + 'signerSpkiSha256', 'volumeSerial', + ]) + || authorityManifest.compiler.kind !== 'windows-catalog-authorized-dotnet-framework-csc-v1' + || !/^(?:Framework64|Framework)-v4\.0\.30319$/.test(String(authorityManifest.compiler.framework)) + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.compiler.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.compiler.signerSpkiSha256)) + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.compiler.signerRootSpkiSha256)) + || !/^[a-f0-9]{16}$/.test(String(authorityManifest.compiler.volumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(authorityManifest.compiler.fileId128)) + || !Array.isArray(authorityManifest.compiler.inputs) || authorityManifest.compiler.inputs.length !== 3 + || authorityManifest.compiler.inputs.map(input => input?.name).join(',') + !== 'csc.exe,System.dll,System.Web.Extensions.dll' + || authorityManifest.compiler.inputs.some(input => !input || typeof input !== 'object' || Array.isArray(input) + || JSON.stringify(Object.keys(input).sort()) !== JSON.stringify([ + 'catalogFileId128', 'catalogName', 'catalogSha256', 'catalogVolumeSerial', 'name', 'sha256', 'signerCertificateSha256', + 'signerRootSpkiSha256', 'signerSpkiSha256', 'size', + ]) + || !Number.isSafeInteger(input.size) || input.size <= 0 || input.size > 32 * 1024 * 1024 + || !/^[a-f0-9]{64}$/.test(String(input.sha256)) + || !/^[a-f0-9]{64}$/.test(String(input.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(input.signerSpkiSha256)) + || !/^[a-f0-9]{64}$/.test(String(input.signerRootSpkiSha256)) + || input.signerCertificateSha256 !== '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de' + || input.signerSpkiSha256 !== 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1' + || !((packagedArchitecture === 'x64' + && input.catalogName === 'Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat' + && input.catalogSha256 === 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef') + || (packagedArchitecture === 'arm64' + && input.catalogName === 'Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat' + && input.catalogSha256 === 'fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85')) + || !/^[a-f0-9]{16}$/.test(String(input.catalogVolumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(input.catalogFileId128))) + || authorityManifest.compiler.inputs[0].signerCertificateSha256 + !== authorityManifest.compiler.signerCertificateSha256 + || authorityManifest.compiler.inputs[0].signerSpkiSha256 !== authorityManifest.compiler.signerSpkiSha256 + || authorityManifest.compiler.inputs[0].signerRootSpkiSha256 + !== authorityManifest.compiler.signerRootSpkiSha256 + || !['unsigned-validation', 'production-signed'].includes(authorityManifest.trust) + || !Array.isArray(authorityManifest.signerPins) || authorityManifest.signerPins.length > 16 + || authorityManifest.signerPins.some(pin => typeof pin !== 'string' + || !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(authorityManifest.signerPins).size !== authorityManifest.signerPins.length + || authorityManifest.signerPins.join(',') !== [...authorityManifest.signerPins].sort().join(',') + || (authorityManifest.trust === 'unsigned-validation' + && (authorityManifest.publisher !== null || authorityManifest.signerPins.length !== 0 + || authorityManifest.signerCertificateSha256 !== null || authorityManifest.signerSpkiSha256 !== null)) + || (authorityManifest.trust === 'production-signed' + && (typeof authorityManifest.publisher !== 'string' || !authorityManifest.publisher + || authorityManifest.signerPins.length === 0 + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.signerSpkiSha256)) + || !authorityManifest.signerPins.some(pin => + pin === `certificate-sha256:${authorityManifest.signerCertificateSha256}` + || pin === `spki-sha256:${authorityManifest.signerSpkiSha256}`))) + || authorityManifest.size !== authorityExecutableBytes.length + || authorityManifest.sha256 !== createHash('sha256').update(authorityExecutableBytes).digest('hex') + || !authorityManifest.launcher || typeof authorityManifest.launcher !== 'object' + || JSON.stringify(Object.keys(authorityManifest.launcher).sort()) !== JSON.stringify([ + 'architecture', 'format', 'machine', 'name', 'publisher', 'sha256', 'signerCertificateSha256', + 'signerPins', 'signerSpkiSha256', 'size', 'trust', + ]) + || authorityManifest.launcher.name !== 'propr-windows-launcher.node' + || authorityManifest.launcher.format !== 'PE' + || authorityManifest.launcher.architecture !== packagedArchitecture + || authorityManifest.launcher.machine !== (packagedArchitecture === 'arm64' ? 'ARM64' : 'AMD64') + || launcherInspection.format !== 'pe' || launcherInspection.architectures.length !== 1 + || launcherInspection.architectures[0] !== packagedArchitecture + || authorityManifest.launcher.size !== authorityLauncherBytes.length + || authorityManifest.launcher.sha256 !== createHash('sha256').update(authorityLauncherBytes).digest('hex') + || authorityManifest.launcher.trust !== authorityManifest.trust + || authorityManifest.launcher.publisher !== authorityManifest.publisher + || JSON.stringify(authorityManifest.launcher.signerPins) !== JSON.stringify(authorityManifest.signerPins) + || authorityManifest.launcher.signerCertificateSha256 !== authorityManifest.signerCertificateSha256 + || authorityManifest.launcher.signerSpkiSha256 !== authorityManifest.signerSpkiSha256 + || !authorityManifest.bootstrap || typeof authorityManifest.bootstrap !== 'object' + || Array.isArray(authorityManifest.bootstrap) + || JSON.stringify(Object.keys(authorityManifest.bootstrap).sort()) !== JSON.stringify([ + 'architecture', 'format', 'machine', 'name', 'publisher', 'sha256', 'signerCertificateSha256', + 'signerPins', 'signerSpkiSha256', 'size', 'trust', + ]) + || authorityManifest.bootstrap.name !== 'propr-windows-bootstrap.node' + || authorityManifest.bootstrap.format !== 'PE' + || authorityManifest.bootstrap.architecture !== packagedArchitecture + || authorityManifest.bootstrap.machine !== (packagedArchitecture === 'arm64' ? 'ARM64' : 'AMD64') + || bootstrapInspection.format !== 'pe' || bootstrapInspection.architectures.length !== 1 + || bootstrapInspection.architectures[0] !== packagedArchitecture + || authorityManifest.bootstrap.size !== authorityBootstrapBytes.length + || authorityManifest.bootstrap.sha256 !== createHash('sha256').update(authorityBootstrapBytes).digest('hex') + || authorityManifest.bootstrap.trust !== authorityManifest.trust + || authorityManifest.bootstrap.publisher !== authorityManifest.publisher + || JSON.stringify(authorityManifest.bootstrap.signerPins) !== JSON.stringify(authorityManifest.signerPins) + || authorityManifest.bootstrap.signerCertificateSha256 !== authorityManifest.signerCertificateSha256 + || authorityManifest.bootstrap.signerSpkiSha256 !== authorityManifest.signerSpkiSha256 + || !/^[a-f0-9]{64}$/.test(String(authorityManifest.sourceSha256))) { + throw new Error('NUPKG Windows authority helper does not match its bound manifest'); + } + const peOffset = authorityExecutableBytes.length >= 512 ? authorityExecutableBytes.readUInt32LE(0x3c) : -1; + const optional = peOffset + 24; + const clrDirectory = optional + 96 + (14 * 8); + if (peOffset < 0x40 || clrDirectory + 8 > authorityExecutableBytes.length + || authorityExecutableBytes.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0' + || authorityExecutableBytes.readUInt16LE(peOffset + 4) !== 0x14c + || authorityExecutableBytes.readUInt16LE(optional) !== 0x10b + || authorityExecutableBytes.readUInt32LE(clrDirectory) === 0) { + throw new Error('NUPKG Windows authority helper is not the expected managed AnyCPU PE32 executable'); + } + const sectionCount = authorityExecutableBytes.readUInt16LE(peOffset + 6); + const optionalSize = authorityExecutableBytes.readUInt16LE(peOffset + 20); + const clrRva = authorityExecutableBytes.readUInt32LE(clrDirectory); + const sectionTable = optional + optionalSize; + let clrOffset = -1; + for (let index = 0; index < sectionCount; index += 1) { + const section = sectionTable + (index * 40); + if (section + 40 > authorityExecutableBytes.length) break; + const virtualSize = authorityExecutableBytes.readUInt32LE(section + 8); + const virtualAddress = authorityExecutableBytes.readUInt32LE(section + 12); + const rawSize = authorityExecutableBytes.readUInt32LE(section + 16); + const rawAddress = authorityExecutableBytes.readUInt32LE(section + 20); + if (clrRva >= virtualAddress && clrRva < virtualAddress + Math.max(virtualSize, rawSize)) { + clrOffset = rawAddress + clrRva - virtualAddress; + } + } + const corFlags = clrOffset >= 0 && clrOffset + 20 <= authorityExecutableBytes.length + ? authorityExecutableBytes.readUInt32LE(clrOffset + 16) + : 0; + if (sectionCount <= 0 || sectionCount > 96 || optionalSize < 224 + || (corFlags & 0x1) === 0 || (corFlags & (0x2 | 0x10 | 0x20000)) !== 0) { + throw new Error('NUPKG Windows authority helper is not the expected managed AnyCPU PE32 executable'); + } + } + return executableBytes; + } finally { + await handle.close(); + } +}; + +const runPipeline = (firstCommand, firstArgs, secondCommand, secondArgs, cwd) => new Promise((resolve, reject) => { + const first = spawn(firstCommand, firstArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + const second = spawn(secondCommand, secondArgs, { cwd, stdio: ['pipe', 'ignore', 'pipe'] }); + let errors = ''; + first.stderr.on('data', chunk => { errors += chunk; }); + second.stderr.on('data', chunk => { errors += chunk; }); + first.stdout.pipe(second.stdin); + let firstCode; + let secondCode; + const complete = () => { + if (firstCode === undefined || secondCode === undefined) return; + if (firstCode === 0 && secondCode === 0) resolve(); + else reject(new Error(`${firstCommand}/${secondCommand} failed: ${errors.trim()}`)); + }; + first.on('error', reject); + second.on('error', reject); + first.on('close', code => { firstCode = code; complete(); }); + second.on('close', code => { secondCode = code; complete(); }); +}); + +const inspectDeb = async (path, platform, arch) => { + const { stdout } = await execFile('dpkg-deb', ['--field', path, 'Architecture']); + const packageArchitecture = stdout.trim(); + if (packageArchitecture !== EXPECTED_PACKAGE_ARCHITECTURE.deb[arch]) { + throw new Error(`DEB architecture mismatch: expected ${EXPECTED_PACKAGE_ARCHITECTURE.deb[arch]}, found ${packageArchitecture}`); + } + const directory = await mkdtemp(join(tmpdir(), 'propr-deb-')); + try { + await execFile('dpkg-deb', ['--extract', path, directory]); + const executable = await inspectLinuxPackageLayout({ root: directory, packageFormat: 'deb', platform, arch, artifact: path }); + return { format: 'deb', packageArchitecture, executable }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +const inspectRpm = async (path, platform, arch) => { + const { stdout } = await execFile('rpm', ['-qp', '--qf', '%{ARCH}', path]); + const packageArchitecture = stdout.trim(); + if (packageArchitecture !== EXPECTED_PACKAGE_ARCHITECTURE.rpm[arch]) { + throw new Error(`RPM architecture mismatch: expected ${EXPECTED_PACKAGE_ARCHITECTURE.rpm[arch]}, found ${packageArchitecture}`); + } + const directory = await mkdtemp(join(tmpdir(), 'propr-rpm-')); + try { + await runPipeline('rpm2cpio', [path], 'cpio', ['-idm', '--quiet'], directory); + const executable = await inspectLinuxPackageLayout({ root: directory, packageFormat: 'rpm', platform, arch, artifact: path }); + return { format: 'rpm', packageArchitecture, executable }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +const execFileWithHeldDescriptor = (file, arguments_, descriptor) => new Promise((resolvePromise, rejectPromise) => { + const child = spawn(file, arguments_, { + stdio: ['ignore', 'pipe', 'pipe', descriptor], + }); + const stdout = []; + const stderr = []; + let outputBytes = 0; + const collect = destination => chunk => { + outputBytes += chunk.length; + if (outputBytes > 16 * 1024 * 1024) { + child.kill(); + rejectPromise(new Error(`${file} produced excessive output`)); + return; + } + destination.push(chunk); + }; + child.stdout.on('data', collect(stdout)); + child.stderr.on('data', collect(stderr)); + child.once('error', rejectPromise); + child.once('close', (code, signal) => { + const standardOutput = Buffer.concat(stdout).toString('utf8'); + const standardError = Buffer.concat(stderr).toString('utf8'); + if (code === 0) { + resolvePromise({ stdout: standardOutput, stderr: standardError }); + return; + } + rejectPromise(new Error( + `${file} exited with ${signal ? `signal ${signal}` : `code ${code}`}${standardError ? `: ${standardError.trim()}` : ''}`, + )); + }); +}); + +const attachPrivateDmg = async (heldArtifact, directory) => { + const { handle, privatePath } = requireHeldDmgArtifact(heldArtifact); + if (!privatePath) { + throw new Error('Native DMG inspection requires an internal private-snapshot pathname capability'); + } + let heldStats; + let pathStats; + try { + [heldStats, pathStats] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(privatePath, { bigint: true }), + ]); + } catch { + throw new Error('Native DMG inspection could not prove the held private-snapshot pathname capability'); + } + if (!heldStats.isFile() + || !pathStats.isFile() + || pathStats.isSymbolicLink() + || heldStats.dev !== pathStats.dev + || heldStats.ino !== pathStats.ino + || heldStats.mode !== pathStats.mode + || heldStats.nlink !== 1n + || pathStats.nlink !== 1n + || heldStats.size !== pathStats.size + || (pathStats.mode & 0o777n) !== 0o600n + || typeof process.getuid !== 'function' + || pathStats.uid !== BigInt(process.getuid())) { + throw new Error('Native DMG inspection rejected an invalid private-snapshot pathname capability'); + } + try { + await execFile(HDIUTIL, ['attach', '-readonly', '-nobrowse', '-mountpoint', directory, privatePath]); + } catch { + // hdiutil includes its source argument in some failures. Keep the internal + // randomized pathname out of logs while still failing closed. + throw new Error('Native read-only DMG attach failed for the held private snapshot'); + } +}; + +const inspectDmg = async (heldArtifact, platform, arch, onDmgMounted) => { + const { handle, description } = requireHeldDmgArtifact(heldArtifact); + const directory = await mkdtemp(join(tmpdir(), 'propr-dmg-')); + let mounted = false; + try { + if (process.platform === 'darwin') { + await attachPrivateDmg(heldArtifact, directory); + mounted = true; + if (onDmgMounted) await onDmgMounted(); + const executable = await inspectDmgLayout({ root: directory, platform, arch, artifact: description }); + return { + format: 'dmg', + executable, + nativeValidation: nativeDmgLayoutEvidence(arch), + }; + } else { + await execFileWithHeldDescriptor( + '7z', + ['x', '-y', '-bso0', '-bsp0', `-o${directory}`, '/dev/fd/3'], + handle.fd, + ); + const executable = await inspectExtractedDmgArchitecture({ root: directory, platform, arch, artifact: description }); + return { format: 'dmg', executable }; + } + } finally { + try { + if (mounted) await execFile(HDIUTIL, ['detach', directory]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + } +}; + +const dmgExecutableLayout = arch => ({ + topLevelApplication: `${EXECUTABLE_NAME}.app`, + installLink: { + path: DMG_INSTALL_LINK, + type: 'symbolic-link', + target: '/Applications', + }, + mainExecutable: { + path: `${EXECUTABLE_NAME}.app/Contents/MacOS/${EXECUTABLE_NAME}`, + format: 'mach-o', + architectures: [arch], + }, + helperExecutables: [...DMG_HELPER_BUNDLES].map(bundle => { + const executable = bundle.slice(0, -'.app'.length); + return { + bundle, + path: `${EXECUTABLE_NAME}.app/Contents/Frameworks/${bundle}/Contents/MacOS/${executable}`, + format: 'mach-o', + architectures: [arch], + }; + }), +}); + +const nativeDmgLayoutEvidence = arch => ({ + ...NATIVE_DMG_VALIDATOR, + layout: dmgExecutableLayout(arch), +}); + +export const inspectExtractedDmgArchitecture = async ({ root, platform, arch, artifact }) => { + if (platform !== 'darwin') throw new Error(`${artifact} DMG is only valid for macOS targets`); + const rootPath = resolve(root); + const layout = dmgExecutableLayout(arch); + const executablePaths = [layout.mainExecutable, ...layout.helperExecutables]; + let mainInspection; + for (const entry of executablePaths) { + const path = join(rootPath, ...entry.path.split('/')); + let stats; + try { stats = await lstat(path); } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`DMG is missing canonical executable ${entry.path}`); + throw error; + } + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`DMG canonical executable ${entry.path} must be a real regular file`); + } + const inspection = inspectExecutableBytes(await readPrefix(path)); + assertExecutableArchitecture(inspection, platform, arch, artifact); + if (entry === layout.mainExecutable) mainInspection = inspection; + } + return mainInspection; +}; + +export const inspectDmgLayout = async ({ root, platform, arch, artifact }) => { + if (platform !== 'darwin') throw new Error(`${artifact} DMG is only valid for macOS targets`); + const rootPath = resolve(root); + const application = join(rootPath, `${EXECUTABLE_NAME}.app`); + const contents = join(application, 'Contents'); + const macos = join(contents, 'MacOS'); + const executable = join(macos, EXECUTABLE_NAME); + const helperDirectory = join(contents, 'Frameworks'); + const installLink = join(rootPath, DMG_INSTALL_LINK); + const canonicalPaths = [ + [application, `${EXECUTABLE_NAME}.app`, 'directory'], + [contents, `${EXECUTABLE_NAME}.app/Contents`, 'directory'], + [macos, `${EXECUTABLE_NAME}.app/Contents/MacOS`, 'directory'], + [executable, `${EXECUTABLE_NAME}.app/Contents/MacOS/${EXECUTABLE_NAME}`, 'regular file'], + [helperDirectory, `${EXECUTABLE_NAME}.app/Contents/Frameworks`, 'directory'], + ]; + for (const helperBundle of DMG_HELPER_BUNDLES) { + const helperName = helperBundle.slice(0, -'.app'.length); + const helper = join(helperDirectory, helperBundle); + const helperContents = join(helper, 'Contents'); + const helperMacos = join(helperContents, 'MacOS'); + canonicalPaths.push( + [helper, `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}`, 'directory'], + [helperContents, `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}/Contents`, 'directory'], + [helperMacos, `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}/Contents/MacOS`, 'directory'], + [join(helperMacos, helperName), `${EXECUTABLE_NAME}.app/Contents/Frameworks/${helperBundle}/Contents/MacOS/${helperName}`, 'regular file'], + ); + } + for (const [path, description, expectedType] of canonicalPaths) { + let stats; + try { stats = await lstat(path); } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`DMG is missing canonical ${description}`); + throw error; + } + if (describeFileType(stats) !== expectedType) { + throw new Error(`DMG canonical ${description} must be a real ${expectedType}, found ${describeFileType(stats)}`); + } + } + let installLinkStats; + try { installLinkStats = await lstat(installLink); } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`DMG is missing canonical ${DMG_INSTALL_LINK} install link`); + throw error; + } + if (!installLinkStats.isSymbolicLink() || await readlink(installLink) !== '/Applications') { + throw new Error(`DMG canonical ${DMG_INSTALL_LINK} install link must be the exact /Applications symbolic link`); + } + + const topLevel = await readdir(rootPath, { withFileTypes: true }); + const topLevelCaseNames = new Set(); + for (const entry of topLevel) { + const caseName = entry.name.toLocaleLowerCase('en-US'); + if (topLevelCaseNames.has(caseName)) throw new Error(`DMG has duplicate or case-colliding top-level entry ${entry.name}`); + topLevelCaseNames.add(caseName); + } + const allowedTopLevel = new Set([`${EXECUTABLE_NAME}.app`, DMG_INSTALL_LINK]); + if (topLevel.length !== allowedTopLevel.size || topLevel.some(entry => !allowedTopLevel.has(entry.name))) { + throw new Error(`DMG contains an unclaimed or alternate top-level payload; expected only ${[...allowedTopLevel].join(' and ')}`); + } + + const applications = []; + const sameNameExecutables = []; + const applicationEntries = [{ + path: `${EXECUTABLE_NAME}.app`, + symbolicLink: false, + directory: true, + }]; + const casePaths = new Map(); + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + const stats = await lstat(entryPath); + const relativePath = displayPackagePath(rootPath, entryPath); + const casePath = relativePath.toLocaleLowerCase('en-US'); + if (casePaths.has(casePath) && casePaths.get(casePath) !== relativePath) { + throw new Error(`DMG contains duplicate or case-colliding application path ${relativePath}`); + } + casePaths.set(casePath, relativePath); + if (entry.name.toLocaleLowerCase('en-US') === EXECUTABLE_NAME) sameNameExecutables.push(entryPath); + if (stats.isSymbolicLink()) { + const frameworkRoot = darwinFrameworkRoot(relativePath); + if (!frameworkRoot) { + throw new Error(`DMG symbolic link ${relativePath} is outside canonical macOS framework internals`); + } + const target = decodeDmgSymlinkTarget(await readlink(entryPath, { encoding: 'buffer' }), relativePath); + applicationEntries.push({ path: relativePath, symbolicLink: true, directory: false, frameworkRoot, target }); + } else if (stats.isDirectory()) { + applicationEntries.push({ path: relativePath, symbolicLink: false, directory: true }); + if (entry.name.toLocaleLowerCase('en-US').endsWith('.app')) applications.push(entryPath); + await visit(entryPath); + } else if (stats.isFile()) { + applicationEntries.push({ path: relativePath, symbolicLink: false, directory: false }); + } else if (!stats.isFile()) { + throw new Error(`DMG contains special file ${relativePath}`); + } + } + }; + await visit(application); + validateDmgFrameworkSymlinks(applicationEntries); + const unexpectedApplications = applications.filter(path => ( + dirname(path) !== helperDirectory || !DMG_HELPER_BUNDLES.has(basename(path)) + )); + if (unexpectedApplications.length > 0) { + throw new Error(`DMG contains an alternate application bundle outside the canonical Electron helper layout`); + } + const helperNames = new Set(applications.map(path => basename(path))); + if (helperNames.size !== DMG_HELPER_BUNDLES.size + || [...DMG_HELPER_BUNDLES].some(name => !helperNames.has(name))) { + throw new Error('DMG canonical application is missing a required Electron helper bundle'); + } + for (const helperBundle of DMG_HELPER_BUNDLES) { + const helperName = helperBundle.slice(0, -'.app'.length); + const helperExecutable = join(helperDirectory, helperBundle, 'Contents', 'MacOS', helperName); + const helperInspection = inspectExecutableBytes(await readPrefix(helperExecutable)); + assertExecutableArchitecture(helperInspection, platform, arch, artifact); + } + if (sameNameExecutables.length !== 1 || sameNameExecutables[0] !== executable) { + throw new Error(`DMG contains a missing or alternate same-name executable outside the canonical application bundle path`); + } + const inspection = inspectExecutableBytes(await readPrefix(executable)); + assertExecutableArchitecture(inspection, platform, arch, artifact); + return inspection; +}; + +export const inspectArtifactArchitecture = async ({ path, heldArtifact, kind, platform, arch, onDmgMounted }) => { + if (kind === 'releases') return { format: 'squirrel-releases', target: `${platform}-${arch}` }; + if (kind === 'deb') return inspectDeb(path, platform, arch); + if (kind === 'rpm') return inspectRpm(path, platform, arch); + if (kind === 'dmg') { + if (path !== undefined) throw new Error('DMG inspection rejects mutable pathnames; pass a held exact-artifact capability'); + if (onDmgMounted !== undefined && typeof onDmgMounted !== 'function') { + throw new Error('DMG mounted callback must be a function'); + } + return inspectDmg(heldArtifact, platform, arch, onDmgMounted); + } + if (kind === 'setup') { + const executable = inspectExecutableBytes(await readPrefix(path)); + if (platform !== 'win32') throw new Error(`${path} Squirrel bootstrapper is only valid for Windows targets`); + assertSupportedSquirrelBootstrap(executable, path); + return { format: 'squirrel-setup', executable }; + } + if (kind === 'msi') return inspectMachineMsi(path, platform, arch); + if (kind === 'zip' || kind === 'nupkg') { + const executable = inspectExecutableBytes(await readValidatedZipExecutable(path, kind, platform, arch)); + assertExecutableArchitecture(executable, platform, arch, path); + return { format: kind, executable }; + } + throw new Error(`Unsupported release artifact format: ${kind}`); +}; + +const argument = name => { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + if (process.argv[2] === 'inspect') { + const path = argument('--path'); + const kind = argument('--kind'); + const platform = argument('--platform'); + const arch = argument('--arch'); + if (!path || !kind || !platform || !arch) throw new Error('Archive inspection requires --path, --kind, --platform, and --arch'); + if (kind === 'dmg') throw new Error('Use release-artifacts staging for private-snapshot DMG inspection'); + console.log(JSON.stringify(await inspectArtifactArchitecture({ path: resolve(path), kind, platform, arch }))); + } else { + throw new Error('Expected release-architecture.mjs inspect command'); + } +} diff --git a/apps/desktop/scripts/release-architecture.test.mjs b/apps/desktop/scripts/release-architecture.test.mjs new file mode 100644 index 000000000..126b5e5c9 --- /dev/null +++ b/apps/desktop/scripts/release-architecture.test.mjs @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + inspectDmgLayout, + inspectExtractedDmgArchitecture, + inspectArtifactArchitecture, + inspectLinuxPackageLayout, +} from './release-architecture.mjs'; + +test('machine-wide Windows artifacts require a real MSI compound file', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-msi-layout-')); + context.after(() => rm(root, { recursive: true, force: true })); + const fake = join(root, 'ProPR-Desktop-Machine-Setup.msi'); + await writeFile(fake, Buffer.alloc(4096)); + await assert.rejects( + inspectArtifactArchitecture({ path: fake, kind: 'msi', platform: 'win32', arch: 'x64' }), + /not a compound-file Windows Installer package/, + ); +}); + +const elfFixture = machine => { + const bytes = Buffer.alloc(64); + Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(bytes); + bytes[5] = 1; + bytes.writeUInt16LE(machine, 18); + return bytes; +}; + +const createLayout = async (root, machine = 62, packageFormat = 'deb') => { + const appDirectory = join(root, 'usr', 'lib', 'propr-desktop'); + const binDirectory = join(root, 'usr', 'bin'); + await mkdir(appDirectory, { recursive: true }); + await mkdir(binDirectory, { recursive: true }); + await writeFile(join(appDirectory, 'propr-desktop'), elfFixture(machine), { mode: 0o755 }); + await symlink('../lib/propr-desktop/propr-desktop', join(binDirectory, 'propr-desktop')); + await mkdir(join(root, 'usr', 'share', 'doc', 'propr-desktop'), { recursive: true }); + if (packageFormat === 'deb') { + const lintianDirectory = join(root, 'usr', 'share', 'lintian', 'overrides'); + await mkdir(lintianDirectory, { recursive: true }); + await writeFile(join(lintianDirectory, 'propr-desktop'), 'propr-desktop: expected-package-override\n'); + } +}; + +const fixture = async (context, machine = 62, packageFormat = 'deb') => { + const root = await mkdtemp(join(tmpdir(), 'propr-linux-layout-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createLayout(root, machine, packageFormat); + return root; +}; + +describe('DEB and RPM executable layouts', () => { + test('accept only the canonical regular ELF payload and documented launcher symlink', async context => { + for (const [format, arch, machine] of [['DEB', 'x64', 62], ['RPM', 'arm64', 183]]) { + const packageFormat = format.toLowerCase(); + const root = await fixture(context, machine, packageFormat); + assert.deepEqual( + await inspectLinuxPackageLayout({ root, packageFormat, platform: 'linux', arch, artifact: `${format} fixture` }), + { format: 'elf', architectures: [arch] }, + ); + } + }); + + test('reject missing and extra payload names for both package formats', async context => { + const missingRoot = await fixture(context); + await rm(join(missingRoot, 'usr', 'lib', 'propr-desktop', 'propr-desktop')); + await assert.rejects( + inspectLinuxPackageLayout({ root: missingRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /only the canonical payload and launcher layout/, + ); + + const extraRoot = await fixture(context, 62, 'rpm'); + await mkdir(join(extraRoot, 'opt'), { recursive: true }); + await writeFile(join(extraRoot, 'opt', 'propr-desktop'), elfFixture(62)); + await assert.rejects( + inspectLinuxPackageLayout({ root: extraRoot, packageFormat: 'rpm', platform: 'linux', arch: 'x64', artifact: 'RPM fixture' }), + /opt\/propr-desktop \(regular file\)/, + ); + + const disguisedPayloadRoot = await fixture(context); + await writeFile( + join(disguisedPayloadRoot, 'usr', 'share', 'lintian', 'overrides', 'propr-desktop'), + elfFixture(62), + ); + await assert.rejects( + inspectLinuxPackageLayout({ root: disguisedPayloadRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /lintian override must not contain an extra ELF payload/, + ); + }); + + test('reject unexpected same-name file types and non-ELF or cross-architecture payloads', async context => { + const regularLauncherRoot = await fixture(context); + const regularLauncher = join(regularLauncherRoot, 'usr', 'bin', 'propr-desktop'); + await rm(regularLauncher); + await writeFile(regularLauncher, '#!/bin/sh\n'); + await assert.rejects( + inspectLinuxPackageLayout({ root: regularLauncherRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /usr\/bin\/propr-desktop \(regular file\)/, + ); + + const wrongArchitectureRoot = await fixture(context, 183, 'rpm'); + await assert.rejects( + inspectLinuxPackageLayout({ root: wrongArchitectureRoot, packageFormat: 'rpm', platform: 'linux', arch: 'x64', artifact: 'RPM fixture' }), + /architecture mismatch.*elf\/x64.*elf\/arm64/, + ); + + const invalidPayloadRoot = await fixture(context); + await writeFile(join(invalidPayloadRoot, 'usr', 'lib', 'propr-desktop', 'propr-desktop'), 'launcher text'); + await assert.rejects( + inspectLinuxPackageLayout({ root: invalidPayloadRoot, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /not a recognized.*binary/, + ); + }); + + test('reject launcher escapes, cycles, and targets other than the canonical payload', async context => { + for (const [name, target, pattern] of [ + ['escape', '../../../outside-propr-desktop', /escapes the extraction root/], + ['cycle', 'propr-desktop', /symbolic-link cycle/], + ['mismatch', '../lib/propr-desktop/helper', /must resolve to usr\/lib\/propr-desktop\/propr-desktop/], + ]) { + const root = await fixture(context, 62, 'rpm'); + const launcher = join(root, 'usr', 'bin', 'propr-desktop'); + await rm(launcher); + if (name === 'mismatch') { + await writeFile(join(root, 'usr', 'lib', 'propr-desktop', 'helper'), elfFixture(62)); + } + await symlink(target, launcher); + await assert.rejects( + inspectLinuxPackageLayout({ root, packageFormat: 'rpm', platform: 'linux', arch: 'x64', artifact: 'RPM fixture' }), + pattern, + ); + } + }); + + test('reject special files with the executable name', { skip: process.platform === 'win32' }, async context => { + const root = await fixture(context); + const specialDirectory = join(root, 'var'); + const special = join(specialDirectory, 'propr-desktop'); + await mkdir(specialDirectory, { recursive: true }); + execFileSync('mkfifo', [special]); + await assert.rejects( + inspectLinuxPackageLayout({ root, packageFormat: 'deb', platform: 'linux', arch: 'x64', artifact: 'DEB fixture' }), + /var\/propr-desktop \(special file\)/, + ); + }); +}); + +describe('DMG application layout', { skip: process.platform === 'win32' }, () => { + const createDmgLayout = async root => { + const macos = join(root, 'propr-desktop.app', 'Contents', 'MacOS'); + const frameworks = join(root, 'propr-desktop.app', 'Contents', 'Frameworks'); + await mkdir(macos, { recursive: true }); + const executable = Buffer.alloc(32); + executable.writeUInt32LE(0xfeedfacf, 0); + executable.writeUInt32LE(0x0100000c, 4); + await writeFile(join(macos, 'propr-desktop'), executable, { mode: 0o755 }); + for (const name of [ + 'propr-desktop Helper', + 'propr-desktop Helper (GPU)', + 'propr-desktop Helper (Plugin)', + 'propr-desktop Helper (Renderer)', + ]) { + const helperMacos = join(frameworks, `${name}.app`, 'Contents', 'MacOS'); + await mkdir(helperMacos, { recursive: true }); + await writeFile(join(helperMacos, name), executable, { mode: 0o755 }); + } + const framework = join(frameworks, 'Electron Framework.framework'); + const frameworkVersions = join(framework, 'Versions'); + await mkdir(join(frameworkVersions, 'A', 'Resources'), { recursive: true }); + await mkdir(join(frameworkVersions, 'A', 'Libraries'), { recursive: true }); + await mkdir(join(frameworkVersions, 'A', 'Helpers'), { recursive: true }); + await writeFile(join(frameworkVersions, 'A', 'Electron Framework'), executable, { mode: 0o755 }); + await symlink('A', join(frameworkVersions, 'Current')); + await symlink('Versions/Current/Electron Framework', join(framework, 'Electron Framework')); + await symlink('Versions/Current/Resources', join(framework, 'Resources')); + await symlink('Versions/Current/Libraries', join(framework, 'Libraries')); + await symlink('Versions/Current/Helpers', join(framework, 'Helpers')); + await symlink('/Applications', join(root, 'Applications')); + }; + + test('accepts the real Forge tree with its install link and nested Electron helper bundles', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-layout-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + assert.deepEqual( + await inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + { format: 'mach-o', architectures: ['arm64'] }, + ); + }); + + test('rejects a symbolic-link canonical helper bundle', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-helper-link-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + const frameworks = join(root, 'propr-desktop.app', 'Contents', 'Frameworks'); + const helper = join(frameworks, 'propr-desktop Helper.app'); + await rename(helper, `${helper}.real`); + await symlink('propr-desktop Helper.app.real', helper); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /canonical .*Helper\.app must be a real directory, found symbolic link/, + ); + }); + + test('rejects a symbolic-link canonical helper executable ancestor', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-helper-ancestor-link-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + const helper = join(root, 'propr-desktop.app', 'Contents', 'Frameworks', 'propr-desktop Helper (GPU).app'); + const contents = join(helper, 'Contents'); + await rename(contents, join(helper, 'RealContents')); + await symlink('RealContents', contents); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /Helper \(GPU\)\.app\/Contents must be a real directory, found symbolic link/, + ); + }); + + test('rejects every symbolic link outside canonical framework internals', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-non-framework-link-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + const resources = join(root, 'propr-desktop.app', 'Contents', 'Resources'); + await mkdir(resources); + await symlink('../MacOS', join(resources, 'MacOS')); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /outside canonical macOS framework internals/, + ); + }); + + test('rejects escaping, cyclic, missing, and case-mismatched framework symbolic links', async context => { + for (const [name, alter, pattern] of [ + ['escape', async framework => { + await rm(join(framework, 'Resources')); + await symlink('../../../../MacOS', join(framework, 'Resources')); + }, /unsafe relative target/], + ['cycle', async framework => { + const versions = join(framework, 'Versions'); + await rm(join(versions, 'Current')); + await symlink('B', join(versions, 'Current')); + await symlink('Current', join(versions, 'B')); + }, /contains a cycle/], + ['missing', async framework => { + await rm(join(framework, 'Resources')); + await symlink('Versions/B/Resources', join(framework, 'Resources')); + }, /missing target/], + ['case-mismatched', async framework => { + await rm(join(framework, 'Resources')); + await symlink('Versions/a/Resources', join(framework, 'Resources')); + }, /missing target/], + ]) { + const root = await mkdtemp(join(tmpdir(), `propr-dmg-framework-${name}-`)); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + await alter(join(root, 'propr-desktop.app', 'Contents', 'Frameworks', 'Electron Framework.framework')); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + pattern, + name, + ); + } + }); + + test('never treats Linux 7z sanitized install-link output as native layout evidence', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-sanitized-')); + context.after(() => rm(root, { recursive: true, force: true })); + await createDmgLayout(root); + await rm(join(root, 'Applications')); + await writeFile(join(root, 'Applications'), '/Applications'); + await assert.rejects( + inspectDmgLayout({ root, platform: 'darwin', arch: 'arm64', artifact: '7z DMG fixture' }), + /exact \/Applications symbolic link/, + ); + assert.deepEqual( + await inspectExtractedDmgArchitecture({ root, platform: 'darwin', arch: 'arm64', artifact: '7z DMG fixture' }), + { format: 'mach-o', architectures: ['arm64'] }, + ); + }); + + test('rejects wrong bundles, alternate same-name executables, and canonical symlink escapes', async context => { + const wrongBundle = await mkdtemp(join(tmpdir(), 'propr-dmg-wrong-bundle-')); + context.after(() => rm(wrongBundle, { recursive: true, force: true })); + await mkdir(join(wrongBundle, 'Wrong.app', 'Contents', 'MacOS'), { recursive: true }); + await assert.rejects( + inspectDmgLayout({ root: wrongBundle, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /missing canonical propr-desktop\.app/, + ); + + const alternate = await mkdtemp(join(tmpdir(), 'propr-dmg-alternate-')); + context.after(() => rm(alternate, { recursive: true, force: true })); + await createDmgLayout(alternate); + const resources = join(alternate, 'propr-desktop.app', 'Contents', 'Resources'); + await mkdir(resources, { recursive: true }); + await writeFile(join(resources, 'propr-desktop'), 'alternate'); + await assert.rejects( + inspectDmgLayout({ root: alternate, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /alternate same-name executable/, + ); + + const escaped = await mkdtemp(join(tmpdir(), 'propr-dmg-symlink-')); + context.after(() => rm(escaped, { recursive: true, force: true })); + await mkdir(join(escaped, 'propr-desktop.app', 'Contents', 'MacOS'), { recursive: true }); + await writeFile(join(escaped, 'outside'), 'outside'); + await symlink('/Applications', join(escaped, 'Applications')); + await symlink('../../../outside', join(escaped, 'propr-desktop.app', 'Contents', 'MacOS', 'propr-desktop')); + await assert.rejects( + inspectDmgLayout({ root: escaped, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /must be a real regular file.*symbolic link/, + ); + }); + + test('rejects alternate top-level application bundles', async context => { + const alternateRoot = await mkdtemp(join(tmpdir(), 'propr-dmg-extra-root-')); + context.after(() => rm(alternateRoot, { recursive: true, force: true })); + await createDmgLayout(alternateRoot); + await mkdir(join(alternateRoot, 'Other.app')); + await assert.rejects( + inspectDmgLayout({ root: alternateRoot, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /unclaimed or alternate top-level payload/, + ); + }); + + test('rejects unsafe links inside the canonical application bundle', async context => { + const unsafeLink = await mkdtemp(join(tmpdir(), 'propr-dmg-unsafe-link-')); + context.after(() => rm(unsafeLink, { recursive: true, force: true })); + await createDmgLayout(unsafeLink); + await symlink('/tmp/escape', join(unsafeLink, 'propr-desktop.app', 'Contents', 'escape')); + await assert.rejects( + inspectDmgLayout({ root: unsafeLink, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /outside canonical macOS framework internals/, + ); + }); + + test('rejects non-helper nested application bundles', async context => { + const nestedApp = await mkdtemp(join(tmpdir(), 'propr-dmg-nested-app-')); + context.after(() => rm(nestedApp, { recursive: true, force: true })); + await createDmgLayout(nestedApp); + await mkdir(join(nestedApp, 'propr-desktop.app', 'Contents', 'Resources', 'Alternate.app'), { recursive: true }); + await assert.rejects( + inspectDmgLayout({ root: nestedApp, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /alternate application bundle/, + ); + }); + + test('rejects case-colliding top-level entries when the filesystem permits them', async context => { + const caseCollision = await mkdtemp(join(tmpdir(), 'propr-dmg-case-collision-')); + context.after(() => rm(caseCollision, { recursive: true, force: true })); + await createDmgLayout(caseCollision); + try { + await symlink('/Applications', join(caseCollision, 'applications')); + } catch (error) { + if (error?.code === 'EEXIST') { + context.skip('filesystem does not permit distinct case-colliding entries'); + return; + } + throw error; + } + await assert.rejects( + inspectDmgLayout({ root: caseCollision, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /duplicate or case-colliding top-level entry/, + ); + }); + + test('rejects special files inside the canonical application bundle', async context => { + const special = await mkdtemp(join(tmpdir(), 'propr-dmg-special-')); + context.after(() => rm(special, { recursive: true, force: true })); + await createDmgLayout(special); + execFileSync('mkfifo', [join(special, 'propr-desktop.app', 'Contents', 'special')]); + await assert.rejects( + inspectDmgLayout({ root: special, platform: 'darwin', arch: 'arm64', artifact: 'DMG fixture' }), + /special file/, + ); + }); +}); diff --git a/apps/desktop/scripts/release-artifacts.mjs b/apps/desktop/scripts/release-artifacts.mjs new file mode 100644 index 000000000..3933dd3a6 --- /dev/null +++ b/apps/desktop/scripts/release-artifacts.mjs @@ -0,0 +1,1128 @@ +import { createHash, createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + createHeldDmgArtifact, + inspectArtifactArchitecture, + NATIVE_DMG_VALIDATOR, +} from './release-architecture.mjs'; + +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const WINDOWS_SIGNER_PIN_PATTERN = /^(?:certificate|spki)-sha256:[a-f0-9]{64}$/; +const SHA1_PATTERN = /^[a-fA-F0-9]{40}$/; +const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true }); +const TARGETS = new Map([ + ['linux-x64', ['deb', 'rpm', 'zip']], + ['linux-arm64', ['deb', 'rpm', 'zip']], + ['darwin-x64', ['dmg', 'zip']], + ['darwin-arm64', ['dmg', 'zip']], + ['win32-x64', ['setup', 'msi', 'nupkg', 'releases']], + ['win32-arm64', ['setup', 'msi', 'nupkg', 'releases']], +]); +const DMG_HELPERS = [ + 'propr-desktop Helper.app', + 'propr-desktop Helper (GPU).app', + 'propr-desktop Helper (Plugin).app', + 'propr-desktop Helper (Renderer).app', +]; + +const requireExactKeys = (value, keys, label) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Object.keys(value); + if (actual.length !== keys.length || actual.some(key => !keys.includes(key))) { + throw new Error(`${label} has missing or unknown keys`); + } +}; + +const expectedDmgLayout = arch => ({ + topLevelApplication: 'propr-desktop.app', + installLink: { path: 'Applications', type: 'symbolic-link', target: '/Applications' }, + mainExecutable: { + path: 'propr-desktop.app/Contents/MacOS/propr-desktop', + format: 'mach-o', + architectures: [arch], + }, + helperExecutables: DMG_HELPERS.map(bundle => ({ + bundle, + path: `propr-desktop.app/Contents/Frameworks/${bundle}/Contents/MacOS/${bundle.slice(0, -'.app'.length)}`, + format: 'mach-o', + architectures: [arch], + })), +}); + +const validateExecutableLayoutEvidence = (value, expected, label, { helper = false } = {}) => { + requireExactKeys(value, helper + ? ['bundle', 'path', 'format', 'architectures'] + : ['path', 'format', 'architectures'], label); + if ((helper && value.bundle !== expected.bundle) + || value.path !== expected.path + || value.format !== 'mach-o' + || !Array.isArray(value.architectures) + || value.architectures.length !== 1 + || value.architectures[0] !== expected.architectures[0]) { + throw new Error(`${label} does not match the canonical native Mach-O layout`); + } +}; + +const validateDmgLayoutEvidence = (value, arch, label) => { + requireExactKeys(value, ['topLevelApplication', 'installLink', 'mainExecutable', 'helperExecutables'], label); + const expected = expectedDmgLayout(arch); + if (value.topLevelApplication !== expected.topLevelApplication) { + throw new Error(`${label} has a noncanonical top-level application`); + } + requireExactKeys(value.installLink, ['path', 'type', 'target'], `${label}.installLink`); + if (value.installLink.path !== expected.installLink.path + || value.installLink.type !== expected.installLink.type + || value.installLink.target !== expected.installLink.target) { + throw new Error(`${label} does not claim the exact native /Applications symbolic link`); + } + validateExecutableLayoutEvidence(value.mainExecutable, expected.mainExecutable, `${label}.mainExecutable`); + if (!Array.isArray(value.helperExecutables) || value.helperExecutables.length !== expected.helperExecutables.length) { + throw new Error(`${label}.helperExecutables must contain the exact canonical helper set`); + } + value.helperExecutables.forEach((helper, index) => { + validateExecutableLayoutEvidence(helper, expected.helperExecutables[index], `${label}.helperExecutables[${index}]`, { helper: true }); + }); +}; + +const createNativeDmgEvidence = ({ target, version, arch, artifact, nativeValidation }) => { + requireExactKeys( + nativeValidation, + ['schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod', 'layout'], + 'Native DMG validation marker', + ); + for (const key of ['schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod']) { + if (nativeValidation[key] !== NATIVE_DMG_VALIDATOR[key]) { + throw new Error(`Native DMG validation marker has an unsupported ${key}`); + } + } + validateDmgLayoutEvidence(nativeValidation.layout, arch, 'Native DMG validation marker layout'); + return { + schemaVersion: NATIVE_DMG_VALIDATOR.schemaVersion, + tool: NATIVE_DMG_VALIDATOR.tool, + toolVersion: NATIVE_DMG_VALIDATOR.toolVersion, + nativePlatform: NATIVE_DMG_VALIDATOR.nativePlatform, + mountMethod: NATIVE_DMG_VALIDATOR.mountMethod, + validatedNatively: true, + target, + version, + architecture: arch, + artifact: { + fileName: artifact.fileName, + size: artifact.size, + sha256: artifact.sha256, + }, + layout: nativeValidation.layout, + }; +}; + +const validateNativeDmgEvidence = (value, { target, version, arch, artifact }) => { + const label = `Native DMG evidence for ${target}`; + requireExactKeys(value, [ + 'schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod', 'validatedNatively', + 'target', 'version', 'architecture', 'artifact', 'layout', + ], label); + for (const key of ['schemaVersion', 'tool', 'toolVersion', 'nativePlatform', 'mountMethod']) { + if (value[key] !== NATIVE_DMG_VALIDATOR[key]) throw new Error(`${label} has an unsupported ${key}`); + } + if (value.validatedNatively !== true) throw new Error(`${label} lacks the native-validation marker`); + if (typeof value.target !== 'string' || value.target.length > 32 || value.target !== target + || typeof value.version !== 'string' || value.version.length > 64 || value.version !== version + || typeof value.architecture !== 'string' || value.architecture.length > 16 || value.architecture !== arch) { + throw new Error(`${label} has mixed, stale, or cross-target metadata`); + } + requireExactKeys(value.artifact, ['fileName', 'size', 'sha256'], `${label}.artifact`); + if (typeof value.artifact.fileName !== 'string' || value.artifact.fileName.length > 255 + || value.artifact.fileName !== artifact.fileName + || !Number.isSafeInteger(value.artifact.size) || value.artifact.size <= 0 || value.artifact.size !== artifact.size + || typeof value.artifact.sha256 !== 'string' || !SHA256_PATTERN.test(value.artifact.sha256) + || value.artifact.sha256 !== artifact.sha256) { + throw new Error(`${label} does not bind the exact canonical DMG bytes`); + } + validateDmgLayoutEvidence(value.layout, arch, `${label}.layout`); +}; + +const recursiveFiles = async directory => { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await recursiveFiles(path)); + else if (entry.isFile()) files.push(path); + } + return files; +}; + +const checksumBytes = value => createHash('sha256').update(value).digest('hex'); +const checksum = async path => checksumBytes(await readFile(path)); +const squirrelChecksumBytes = value => createHash('sha1').update(value).digest('hex'); + +const dmgFileState = stats => ({ + device: stats.dev, + inode: stats.ino, + mode: stats.mode, + links: stats.nlink, + size: stats.size, +}); + +const sameDmgFileState = (left, right) => Object.keys(left).every(key => left[key] === right[key]); + +const checksumDmgHandle = async (handle, size) => { + const hash = createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < size) { + const length = Math.min(buffer.length, size - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead === 0) throw new Error('Staged DMG changed while its exact bytes were captured'); + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + return hash.digest('hex'); +}; + +const captureHeldDmgBytes = async handle => { + const before = await handle.stat({ bigint: true }); + if (!before.isFile()) { + throw new Error('Staged DMG must be a real regular file, not a symbolic link or special file'); + } + if (before.size <= 0n || before.size > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('Staged DMG size must be a positive safe integer'); + } + const size = Number(before.size); + const sha256 = await checksumDmgHandle(handle, size); + const after = await handle.stat({ bigint: true }); + if (!after.isFile() || !sameDmgFileState(dmgFileState(before), dmgFileState(after))) { + throw new Error('Staged DMG identity or content changed while its exact bytes were captured'); + } + return { state: dmgFileState(after), size, sha256 }; +}; + +const assertDmgPathNamesHeldFile = async (path, held) => { + const pathStats = await lstat(path, { bigint: true }); + if (!pathStats.isFile() || pathStats.isSymbolicLink() + || !sameDmgFileState(dmgFileState(pathStats), held.state)) { + throw new Error('Staged DMG pathname no longer names the held exact artifact'); + } +}; + +const isCurrentPosixOwner = stats => process.platform !== 'win32' + && typeof process.getuid === 'function' + && stats.uid === BigInt(process.getuid()); + +const isScopedWindowsDmgFixtureAuthority = authority => process.platform === 'win32' + && authority?.schemaVersion === 1 + && authority?.platform === 'win32' + && authority?.scope === 'release-test-private-dmg' + && Object.keys(authority).length === 3; + +const lstatPrivateDmgPath = async (path, label) => { + try { + return await lstat(path, { bigint: true }); + } catch { + throw new Error(`${label} could not be validated`); + } +}; + +const privateDmgAuthorityError = code => new Error(`Private DMG authority rejected [dmg-private:${code}]`); + +const assertPrivateDmgDirectory = async (path, publicOutputDirectory, fixtureAuthority) => { + const relationship = relative(resolve(publicOutputDirectory), resolve(path)); + if (relationship === '' || (!isAbsolute(relationship) && relationship !== '..' && !relationship.startsWith(`..${sep}`))) { + throw new Error('Private DMG snapshot directory must be outside the public output path'); + } + const stats = await lstatPrivateDmgPath(path, 'Private DMG snapshot directory'); + if (stats.isSymbolicLink()) throw privateDmgAuthorityError('directory-symlink'); + if (!stats.isDirectory()) throw privateDmgAuthorityError('directory-type'); + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && !isCurrentPosixOwner(stats)) { + throw privateDmgAuthorityError('directory-owner'); + } + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && (stats.mode & 0o777n) !== 0o700n) { + throw privateDmgAuthorityError('directory-mode'); + } +}; + +const assertPrivateDmgHeldAuthority = async (handle, fixtureAuthority) => { + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile()) throw privateDmgAuthorityError('file-type'); + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && !isCurrentPosixOwner(stats)) { + throw privateDmgAuthorityError('file-owner'); + } + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && (stats.mode & 0o777n) !== 0o600n) { + throw privateDmgAuthorityError('file-mode'); + } + if (stats.nlink !== 1n) throw privateDmgAuthorityError('file-link'); + return stats; +}; + +const assertPrivateDmgPathNamesHeldFile = async (path, held, fixtureAuthority) => { + const pathStats = await lstatPrivateDmgPath(path, 'Private DMG snapshot pathname'); + if (pathStats.isSymbolicLink()) throw privateDmgAuthorityError('file-symlink'); + if (!pathStats.isFile()) throw privateDmgAuthorityError('file-type'); + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && !isCurrentPosixOwner(pathStats)) { + throw privateDmgAuthorityError('file-owner'); + } + if (!isScopedWindowsDmgFixtureAuthority(fixtureAuthority) && (pathStats.mode & 0o777n) !== 0o600n) { + throw privateDmgAuthorityError('file-mode'); + } + if (pathStats.nlink !== 1n) throw privateDmgAuthorityError('file-link'); + if (!sameDmgFileState(dmgFileState(pathStats), held.state)) throw privateDmgAuthorityError('file-identity'); +}; + +const assertStableDmgBytes = (before, after) => { + if (!sameDmgFileState(before.state, after.state) + || before.size !== after.size + || before.sha256 !== after.sha256) { + throw new Error('Staged DMG identity or content changed during native validation'); + } +}; + +const assertSameDmgContent = (expected, actual) => { + if (expected.size !== actual.size || expected.sha256 !== actual.sha256) { + throw new Error('Copied DMG bytes do not match the held validated artifact'); + } +}; + +const openHeldDmg = async (path, { privateSnapshot = false, fixtureAuthority } = {}) => { + let handle; + try { + handle = await open( + path, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | (privateSnapshot ? 0 : fsConstants.O_NONBLOCK), + ); + } catch (error) { + if (error?.code === 'ELOOP') { + throw new Error('Staged DMG must be a real regular file, not a symbolic link or special file'); + } + throw error; + } + try { + const captured = await captureHeldDmgBytes(handle); + if (privateSnapshot) { + await assertPrivateDmgHeldAuthority(handle, fixtureAuthority); + await assertPrivateDmgPathNamesHeldFile(path, captured, fixtureAuthority); + } + else await assertDmgPathNamesHeldFile(path, captured); + return { handle, captured }; + } catch (error) { + await handle.close(); + throw error; + } +}; + +const copyHeldDmgToExclusivePath = async (handle, size, path) => { + const output = await open( + path, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + try { + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < size) { + const length = Math.min(buffer.length, size - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead === 0) throw new Error('Held DMG changed while it was copied for publication'); + let written = 0; + while (written < bytesRead) { + const result = await output.write(buffer, written, bytesRead - written, position + written); + if (result.bytesWritten === 0) throw new Error('Could not copy held DMG for publication'); + written += result.bytesWritten; + } + position += bytesRead; + } + await output.sync(); + } finally { + await output.close(); + } +}; + +const createPrivateDmgSnapshot = async ({ sourcePath, publicOutputDirectory, description, fixtureAuthority }) => { + const source = await openHeldDmg(sourcePath); + let privateDirectory; + let snapshot; + try { + privateDirectory = await mkdtemp(join(tmpdir(), 'propr-dmg-snapshot-')); + await assertPrivateDmgDirectory(privateDirectory, publicOutputDirectory, fixtureAuthority); + const privatePath = join(privateDirectory, `${randomUUID()}.dmg`); + await copyHeldDmgToExclusivePath(source.handle, source.captured.size, privatePath); + const sourceAfterCopy = await captureHeldDmgBytes(source.handle); + assertStableDmgBytes(source.captured, sourceAfterCopy); + snapshot = await openHeldDmg(privatePath, { privateSnapshot: true, fixtureAuthority }); + assertSameDmgContent(sourceAfterCopy, snapshot.captured); + return { + privateDirectory, + privatePath, + held: snapshot, + heldArtifact: createHeldDmgArtifact(snapshot.handle, description, privatePath), + }; + } catch (error) { + if (snapshot) await snapshot.handle.close(); + if (privateDirectory) { + try { + await rm(privateDirectory, { recursive: true, force: true }); + } catch { + throw new Error('Private DMG snapshot cleanup failed'); + } + } + if (privateDirectory && error?.message?.includes(privateDirectory)) { + throw new Error('Private DMG snapshot creation or validation failed'); + } + throw error; + } finally { + await source.handle.close(); + } +}; + +const closePrivateDmgSnapshot = async snapshot => { + if (!snapshot) return; + try { + await snapshot.held.handle.close(); + } finally { + try { + await rm(snapshot.privateDirectory, { recursive: true, force: true }); + } catch { + throw new Error('Private DMG snapshot cleanup failed'); + } + } +}; + +const publishHeldDmg = async ({ handle, captured, destination }) => { + const temporary = join(dirname(destination), `.${basename(destination)}.${randomUUID()}.tmp`); + try { + await copyHeldDmgToExclusivePath(handle, captured.size, temporary); + const afterCopy = await captureHeldDmgBytes(handle); + assertStableDmgBytes(captured, afterCopy); + const copied = await openHeldDmg(temporary); + let copiedCapture; + try { + assertSameDmgContent(afterCopy, copied.captured); + copiedCapture = copied.captured; + } finally { + await copied.handle.close(); + } + await rename(temporary, destination); + const published = await openHeldDmg(destination); + try { + assertStableDmgBytes(copiedCapture, published.captured); + assertSameDmgContent(afterCopy, published.captured); + } finally { + await published.handle.close(); + } + return afterCopy; + } finally { + await rm(temporary, { force: true }); + } +}; + +const parseWindowsSignerPins = value => { + if (!value) throw new Error('PROPR_DESKTOP_WINDOWS_SIGNER_PINS is required'); + const pins = value.split(','); + if (pins.length > 16 || pins.some(pin => !WINDOWS_SIGNER_PIN_PATTERN.test(pin)) + || new Set(pins).size !== pins.length || pins.join(',') !== [...pins].sort().join(',')) { + throw new Error('PROPR_DESKTOP_WINDOWS_SIGNER_PINS must be a sorted, unique canonical SHA-256 fingerprint allowlist'); + } + return pins; +}; + +const windowsSignerMatchesPins = (signer, pins) => pins.some(pin => ( + pin === `certificate-sha256:${signer.certificateSha256}` + || pin === `spki-sha256:${signer.spkiSha256}` +)); + +export const parseSquirrelReleases = bytes => { + let text; + try { text = STRICT_UTF8.decode(bytes); } catch { throw new Error('Squirrel RELEASES metadata is not valid UTF-8'); } + if (!text || text.includes('\0') || /\r(?!\n)/.test(text)) { + throw new Error('Squirrel RELEASES metadata is empty or has invalid line endings'); + } + const lineEnding = text.includes('\r\n') ? '\r\n' : '\n'; + if (text.includes('\r\n') && text.replaceAll('\r\n', '').includes('\n')) { + throw new Error('Squirrel RELEASES metadata mixes line endings'); + } + const lines = text.split(lineEnding); + const trailingNewline = lines.at(-1) === ''; + if (trailingNewline) lines.pop(); + if (lines.length === 0 || lines.some(line => !line)) { + throw new Error('Squirrel RELEASES metadata must contain only nonempty records'); + } + const records = lines.map(line => { + const match = /^([a-fA-F0-9]{40}) ([^\s/\\]+) ((?:0|[1-9]\d*))$/.exec(line); + if (!match || !SHA1_PATTERN.test(match[1])) throw new Error(`Invalid Squirrel RELEASES record: ${line}`); + const size = Number(match[3]); + if (!Number.isSafeInteger(size) || size <= 0 || !/-full\.nupkg$/.test(match[2]) || /-delta\.nupkg$/i.test(match[2])) { + throw new Error(`Invalid Squirrel RELEASES package record: ${line}`); + } + return { sha1: match[1].toLowerCase(), fileName: match[2], size }; + }); + const names = new Set(); + const caseNames = new Set(); + for (const record of records) { + const caseName = record.fileName.toLocaleLowerCase('en-US'); + if (names.has(record.fileName) || caseNames.has(caseName)) { + throw new Error(`Squirrel RELEASES contains duplicate or case-colliding package ${record.fileName}`); + } + names.add(record.fileName); + caseNames.add(caseName); + } + return { records, lineEnding, trailingNewline }; +}; + +export const validateSquirrelReleases = (releasesBytes, packages) => { + if (!Array.isArray(packages) || packages.length === 0) throw new Error('Staged Squirrel package set is empty'); + const parsed = parseSquirrelReleases(releasesBytes); + const expectedNames = new Set(packages.map(pkg => pkg.fileName)); + if (expectedNames.size !== packages.length || parsed.records.length !== packages.length) { + throw new Error('Squirrel RELEASES record set does not exactly match the staged full NUPKG set'); + } + for (const pkg of packages) { + if (basename(pkg.fileName) !== pkg.fileName || !/-full\.nupkg$/.test(pkg.fileName) || !Buffer.isBuffer(pkg.bytes)) { + throw new Error(`Invalid staged Squirrel package ${pkg.fileName}`); + } + const matches = parsed.records.filter(record => record.fileName === pkg.fileName); + if (matches.length !== 1) { + throw new Error(`Squirrel RELEASES does not contain exactly staged package ${pkg.fileName}`); + } + const record = matches[0]; + if (record.size !== pkg.bytes.length) throw new Error(`Squirrel RELEASES size mismatch for ${pkg.fileName}`); + if (record.sha1 !== squirrelChecksumBytes(pkg.bytes)) throw new Error(`Squirrel RELEASES SHA-1 mismatch for ${pkg.fileName}`); + } + if (parsed.records.some(record => !expectedNames.has(record.fileName))) { + throw new Error('Squirrel RELEASES references a foreign or unstaged package'); + } + return parsed; +}; + +const artifactKind = (path, platform) => { + const name = basename(path); + if (platform === 'win32') { + if (/-Machine-Setup\.msi$/i.test(name)) return 'msi'; + if (/Setup\.exe$/i.test(name)) return 'setup'; + if (/-full\.nupkg$/i.test(name)) return 'nupkg'; + if (name === 'RELEASES') return 'releases'; + return undefined; + } + const extension = name.split('.').at(-1)?.toLowerCase(); + return ['deb', 'rpm', 'zip', 'dmg'].includes(extension) ? extension : undefined; +}; + +const releaseFileName = (version, platform, arch, kind) => { + const platformName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; + const suffix = kind === 'setup' ? 'Setup.exe' + : kind === 'msi' ? 'Machine-Setup.msi' + : kind === 'releases' ? 'RELEASES' : kind === 'nupkg' ? 'full.nupkg' : kind; + return `ProPR-Desktop-${version}-${platformName}-${arch}-${suffix}`; +}; + +const readNativeSigner = (platform, env) => { + if (platform === 'linux') return undefined; + const type = env.PROPR_DESKTOP_ACTUAL_SIGNER_TYPE?.trim(); + const identity = env.PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY?.trim(); + const designatedRequirement = env.PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT?.trim(); + const certificateSha256 = env.PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256?.trim(); + const spkiSha256 = env.PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256?.trim(); + if (!type && !identity && !designatedRequirement && !certificateSha256 && !spkiSha256) return undefined; + const expectedType = platform === 'darwin' ? 'apple-team-id' : 'authenticode-subject'; + if (type !== expectedType || !identity + || (platform === 'darwin' && (!designatedRequirement || certificateSha256 || spkiSha256)) + || (platform === 'win32' && (designatedRequirement + || !SHA256_PATTERN.test(certificateSha256 ?? '') + || !SHA256_PATTERN.test(spkiSha256 ?? '')))) { + throw new Error(`Native signer evidence is incomplete or invalid for ${platform}`); + } + return { + type, + identity, + ...(platform === 'darwin' + ? { designatedRequirement } + : { certificateSha256, spkiSha256 }), + }; +}; + +export const stageArtifacts = async ({ + makeDirectory, + outputDirectory, + platform, + arch, + version, + env = process.env, + inspectArchitecture = inspectArtifactArchitecture, + privateDmgFixtureAuthority, +}) => { + if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const target = `${platform}-${arch}`; + const expectedKinds = TARGETS.get(target); + if (!expectedKinds) throw new Error(`Unsupported desktop release target: ${target}`); + if (platform === 'darwin' && process.platform === 'win32' + && (inspectArchitecture === inspectArtifactArchitecture + || !isScopedWindowsDmgFixtureAuthority(privateDmgFixtureAuthority))) { + throw new Error('Windows-hosted DMG fixtures require an explicit scoped fixture authority and injected inspector'); + } + + const candidates = await recursiveFiles(makeDirectory); + const byKind = new Map(); + for (const path of candidates) { + const kind = artifactKind(path, platform); + if (!kind || !expectedKinds.includes(kind)) continue; + if (byKind.has(kind)) throw new Error(`Found multiple ${kind} artifacts for ${target}`); + byKind.set(kind, path); + } + const missing = expectedKinds.filter(kind => !byKind.has(kind)); + if (missing.length) throw new Error(`Missing ${missing.join(', ')} artifact(s) for ${target}`); + + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + const artifacts = []; + for (const kind of expectedKinds) { + const fileName = releaseFileName(version, platform, arch, kind); + const destination = join(outputDirectory, fileName); + if (kind === 'dmg') { + let snapshot; + try { + snapshot = await createPrivateDmgSnapshot({ + sourcePath: byKind.get(kind), + publicOutputDirectory: outputDirectory, + description: fileName, + fixtureAuthority: privateDmgFixtureAuthority, + }); + const inspection = await inspectArchitecture({ heldArtifact: snapshot.heldArtifact, kind, platform, arch }); + // Authority reasons intentionally precede byte/identity stability after + // any native validation hook. The same descriptor remains authoritative. + await assertPrivateDmgHeldAuthority(snapshot.held.handle, privateDmgFixtureAuthority); + await assertPrivateDmgPathNamesHeldFile( + snapshot.privatePath, + snapshot.held.captured, + privateDmgFixtureAuthority, + ); + const afterInspection = await captureHeldDmgBytes(snapshot.held.handle); + assertStableDmgBytes(snapshot.held.captured, afterInspection); + await assertPrivateDmgPathNamesHeldFile(snapshot.privatePath, afterInspection, privateDmgFixtureAuthority); + const details = await publishHeldDmg({ + handle: snapshot.held.handle, + captured: afterInspection, + destination, + }); + const artifact = { + platform, + arch, + kind, + fileName, + size: details.size, + sha256: details.sha256, + architectureEvidence: { format: inspection.format, executable: inspection.executable }, + }; + artifact.nativeDmgValidationEvidence = createNativeDmgEvidence({ + target, + version, + arch, + artifact, + nativeValidation: inspection.nativeValidation, + }); + artifacts.push(artifact); + } finally { + await closePrivateDmgSnapshot(snapshot); + } + continue; + } + if (kind === 'releases') { + const originalPackageName = basename(byKind.get('nupkg')); + const renamedPackageName = releaseFileName(version, platform, arch, 'nupkg'); + const packageBytes = await readFile(byKind.get('nupkg')); + const releasesBytes = await readFile(byKind.get(kind)); + const parsed = validateSquirrelReleases(releasesBytes, [{ fileName: originalPackageName, bytes: packageBytes }]); + const rendered = parsed.records + .map(record => `${record.sha1} ${record.fileName === originalPackageName ? renamedPackageName : record.fileName} ${record.size}`) + .join(parsed.lineEnding) + (parsed.trailingNewline ? parsed.lineEnding : ''); + const renderedBytes = Buffer.from(rendered); + validateSquirrelReleases(renderedBytes, [{ fileName: renamedPackageName, bytes: packageBytes }]); + await writeFile(destination, renderedBytes); + } else { + await copyFile(byKind.get(kind), destination); + } + const inspection = await inspectArchitecture({ + path: destination, + kind, + platform, + arch, + }); + const details = await stat(destination); + const artifact = { + platform, + arch, + kind, + fileName, + size: details.size, + sha256: await checksum(destination), + architectureEvidence: inspection, + }; + artifacts.push(artifact); + } + const nativeSigner = readNativeSigner(platform, env); + if (env.PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS === '1' && platform !== 'linux' && !nativeSigner) { + throw new Error(`Production ${platform} artifacts require verified native signer evidence`); + } + if (env.PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS === '1' && platform === 'win32') { + const pins = parseWindowsSignerPins(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS); + if (!windowsSignerMatchesPins(nativeSigner, pins)) { + throw new Error('Production Windows signer fingerprint is not in the configured allowlist'); + } + } + const fragment = { + schemaVersion: 2, + version, + tag: `desktop-v${version}`, + target, + artifacts, + nativeSigner, + }; + await writeFile(join(outputDirectory, 'release-fragment.json'), `${JSON.stringify(fragment, null, 2)}\n`); + return fragment; +}; + +export const probePrivateDmgSnapshotIsolation = async ({ makeDirectory, arch, version, env = process.env }) => { + if (process.platform !== 'darwin') { + throw new Error('Private-snapshot DMG isolation probe is available only on native macOS'); + } + const dmgPaths = (await recursiveFiles(makeDirectory)).filter(path => artifactKind(path, 'darwin') === 'dmg'); + if (dmgPaths.length !== 1) throw new Error('Private-snapshot DMG isolation probe requires exactly one source DMG'); + const sourcePath = dmgPaths[0]; + const expected = await openHeldDmg(sourcePath); + const expectedSize = expected.captured.size; + const expectedSha256 = expected.captured.sha256; + await expected.handle.close(); + const outputDirectory = await mkdtemp(join(tmpdir(), 'propr-dmg-isolation-output-')); + const destination = join(outputDirectory, releaseFileName(version, 'darwin', arch, 'dmg')); + const displaced = `${sourcePath}.private-snapshot-isolation-held`; + let sourceDisplaced = false; + try { + const fragment = await stageArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch, + version, + env, + inspectArchitecture: arguments_ => inspectArtifactArchitecture({ + ...arguments_, + ...(arguments_.kind === 'dmg' ? { + onDmgMounted: async () => { + await rename(sourcePath, displaced); + sourceDisplaced = true; + await writeFile(sourcePath, 'hostile replacement of the original pathname'); + await writeFile(destination, 'hostile replacement of the public pathname'); + }, + } : {}), + }), + }); + const artifact = fragment.artifacts.find(candidate => candidate.kind === 'dmg'); + if (!artifact + || artifact.size !== expectedSize + || artifact.sha256 !== expectedSha256 + || artifact.nativeDmgValidationEvidence?.artifact?.sha256 !== expectedSha256 + || await checksum(destination) !== expectedSha256) { + throw new Error('Private-snapshot isolation probe did not keep mounted, evidenced, and published DMG bytes bound to held A'); + } + return { size: expectedSize, sha256: expectedSha256 }; + } finally { + if (sourceDisplaced) { + await rm(sourcePath, { force: true }); + await rename(displaced, sourcePath); + } + await rm(outputDirectory, { recursive: true, force: true }); + } +}; + +const readFragments = async inputDirectory => { + const paths = (await recursiveFiles(inputDirectory)).filter(path => basename(path) === 'release-fragment.json'); + return Promise.all(paths.map(async path => ({ path, value: JSON.parse(await readFile(path, 'utf8')) }))); +}; + +const parseHttpsUrl = (value, name, { allowQuery = true } = {}) => { + let url; + try { url = new URL(value); } catch { throw new Error(`${name} must be an absolute HTTPS URL`); } + if (url.protocol !== 'https:' || url.username || url.password || url.hash || (!allowQuery && url.search)) { + throw new Error(`${name} must be HTTPS and contain no credentials, fragment${allowQuery ? '' : ', or query'}`); + } + return url.toString(); +}; + +export const finalizeArtifacts = async ({ + inputDirectory, + outputDirectory, + version, + inspectArchitecture = inspectArtifactArchitecture, +}) => { + if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const fragments = await readFragments(inputDirectory); + if (fragments.length !== TARGETS.size) { + throw new Error(`Expected ${TARGETS.size} release fragments, found ${fragments.length}`); + } + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + + const seenTargets = new Set(); + const seenNames = new Set(); + const artifacts = []; + const nativeSigners = {}; + for (const { path, value } of fragments) { + if (value.schemaVersion !== 2 || value.version !== version || value.tag !== `desktop-v${version}`) { + throw new Error(`Release fragment metadata does not match desktop-v${version}: ${path}`); + } + const expectedKinds = TARGETS.get(value.target); + if (!expectedKinds || seenTargets.has(value.target)) throw new Error(`Duplicate or invalid target ${value.target}`); + seenTargets.add(value.target); + if (!Array.isArray(value.artifacts) || value.artifacts.length !== expectedKinds.length) { + throw new Error(`Release fragment ${value.target} has an unexpected artifact count`); + } + const [targetPlatform, targetArch] = value.target.split('-'); + const expectedSigner = readNativeSigner(targetPlatform, { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: value.nativeSigner?.type, + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: value.nativeSigner?.identity, + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: value.nativeSigner?.designatedRequirement, + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: value.nativeSigner?.certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: value.nativeSigner?.spkiSha256, + }); + if (expectedSigner) nativeSigners[value.target] = expectedSigner; + for (const artifact of value.artifacts) { + const expectedFileName = releaseFileName(version, targetPlatform, targetArch, artifact.kind); + if ( + !expectedKinds.includes(artifact.kind) + || artifact.platform !== targetPlatform + || artifact.arch !== targetArch + || artifact.fileName !== expectedFileName + || basename(artifact.fileName) !== artifact.fileName + || !Number.isSafeInteger(artifact.size) + || artifact.size <= 0 + || !SHA256_PATTERN.test(artifact.sha256) + || typeof artifact.architectureEvidence !== 'object' + || artifact.architectureEvidence === null + || seenNames.has(artifact.fileName) + ) { + throw new Error(`Release fragment ${value.target} has an invalid or duplicate artifact`); + } + if (artifact.kind === 'dmg') { + validateNativeDmgEvidence(artifact.nativeDmgValidationEvidence, { + target: value.target, + version, + arch: targetArch, + artifact, + }); + } else if (artifact.nativeDmgValidationEvidence !== undefined) { + throw new Error(`Release fragment ${value.target} attaches native DMG evidence to a non-DMG artifact`); + } + const source = join(dirname(path), artifact.fileName); + let inspection; + if (artifact.kind === 'dmg') { + const held = await openHeldDmg(source); + try { + if (held.captured.sha256 !== artifact.sha256 || held.captured.size !== artifact.size) { + throw new Error(`Release artifact integrity does not match its fragment: ${artifact.fileName}`); + } + inspection = await inspectArchitecture({ + heldArtifact: createHeldDmgArtifact(held.handle, artifact.fileName), + kind: artifact.kind, + platform: targetPlatform, + arch: targetArch, + }); + const afterInspection = await captureHeldDmgBytes(held.handle); + assertStableDmgBytes(held.captured, afterInspection); + await assertDmgPathNamesHeldFile(source, afterInspection); + await publishHeldDmg({ + handle: held.handle, + captured: afterInspection, + destination: join(outputDirectory, artifact.fileName), + }); + } finally { + await held.handle.close(); + } + } else { + if (await checksum(source) !== artifact.sha256 || (await stat(source)).size !== artifact.size) { + throw new Error(`Release artifact integrity does not match its fragment: ${artifact.fileName}`); + } + inspection = await inspectArchitecture({ + path: source, + kind: artifact.kind, + platform: targetPlatform, + arch: targetArch, + }); + } + const architectureEvidence = artifact.kind === 'dmg' + ? { format: inspection.format, executable: inspection.executable } + : inspection; + if (JSON.stringify(architectureEvidence) !== JSON.stringify(artifact.architectureEvidence)) { + throw new Error(`Release artifact architecture evidence does not match its fragment: ${artifact.fileName}`); + } + seenNames.add(artifact.fileName); + if (artifact.kind !== 'dmg') await copyFile(source, join(outputDirectory, artifact.fileName)); + artifacts.push(artifact); + } + if (targetPlatform === 'win32') { + const packageArtifact = value.artifacts.find(artifact => artifact.kind === 'nupkg'); + const releasesArtifact = value.artifacts.find(artifact => artifact.kind === 'releases'); + if (!packageArtifact || !releasesArtifact) throw new Error(`Release fragment ${value.target} lacks Squirrel metadata`); + const packageBytes = await readFile(join(dirname(path), packageArtifact.fileName)); + const releasesBytes = await readFile(join(dirname(path), releasesArtifact.fileName)); + try { + validateSquirrelReleases(releasesBytes, [{ fileName: packageArtifact.fileName, bytes: packageBytes }]); + } catch (error) { + throw new Error(`Release fragment ${value.target} has invalid Squirrel RELEASES metadata: ${error.message}`); + } + } + } + for (const target of TARGETS.keys()) { + if (!seenTargets.has(target)) throw new Error(`Missing release target ${target}`); + } + const windowsSigners = ['win32-x64', 'win32-arm64'].map(target => nativeSigners[target]).filter(Boolean); + if (windowsSigners.length === 2 && JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1])) { + throw new Error('Windows release targets contain mixed native signer evidence'); + } + + artifacts.sort((left, right) => left.fileName.localeCompare(right.fileName)); + const publishedAt = process.env.SOURCE_DATE_EPOCH + ? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1_000).toISOString() + : new Date().toISOString(); + const manifest = { + schemaVersion: 2, + channel: 'stable', + version, + tag: `desktop-v${version}`, + publishedAt, + feeds: {}, + nativeSigners, + artifacts, + }; + await writeFile(join(outputDirectory, 'desktop-release.json'), `${JSON.stringify(manifest, null, 2)}\n`); + await writeFile( + join(outputDirectory, 'SHA256SUMS'), + `${artifacts.map(artifact => `${artifact.sha256} ${artifact.fileName}`).join('\n')}\n`, + ); + return manifest; +}; + +const configuredFeedDefinitions = [ + ['darwin-x64', 'PROPR_DESKTOP_DARWIN_X64_FEED_URL'], + ['darwin-arm64', 'PROPR_DESKTOP_DARWIN_ARM64_FEED_URL'], + ['win32-x64', 'PROPR_DESKTOP_WINDOWS_X64_FEED_URL'], + ['win32-arm64', 'PROPR_DESKTOP_WINDOWS_ARM64_FEED_URL'], +]; + +const exactFeedUrl = (target, configured, name) => { + const parsed = new URL(parseHttpsUrl(configured, name)); + if (parsed.pathname.endsWith('/')) { + parsed.pathname += target.startsWith('darwin-') ? 'RELEASES.json' : 'RELEASES'; + } else if (target.startsWith('win32-') && !parsed.pathname.endsWith('/RELEASES')) { + parsed.pathname += '/RELEASES'; + } + return parsed.toString(); +}; + +const createSignedFeeds = async (manifest, outputDirectory, env) => { + const feeds = {}; + const feedFiles = []; + for (const [target, variable] of configuredFeedDefinitions) { + const feedUrl = exactFeedUrl(target, env[variable].trim(), variable); + const updateKind = target.startsWith('darwin-') ? 'zip' : 'nupkg'; + const artifact = manifest.artifacts.find(candidate => `${candidate.platform}-${candidate.arch}` === target && candidate.kind === updateKind); + const signer = manifest.nativeSigners[target]; + if (!artifact || !signer) throw new Error(`Signed update metadata lacks artifact or native signer evidence for ${target}`); + const artifactUrl = new URL(artifact.fileName, feedUrl).toString(); + let feedBytes; + let feedFileName; + if (target.startsWith('darwin-')) { + feedBytes = Buffer.from(`${JSON.stringify({ + url: artifactUrl, + name: manifest.version, + notes: `ProPR Desktop ${manifest.version}`, + pub_date: manifest.publishedAt, + }, null, 2)}\n`); + feedFileName = `ProPR-Desktop-${manifest.version}-macos-${target.split('-')[1]}-RELEASES.json`; + await writeFile(join(outputDirectory, feedFileName), feedBytes); + feedFiles.push({ fileName: feedFileName, size: feedBytes.length, sha256: checksumBytes(feedBytes) }); + } else { + feedFileName = releaseFileName(manifest.version, 'win32', target.split('-')[1], 'releases'); + feedBytes = await readFile(join(outputDirectory, feedFileName)); + const packageBytes = await readFile(join(outputDirectory, artifact.fileName)); + try { + validateSquirrelReleases(feedBytes, [{ fileName: artifact.fileName, bytes: packageBytes }]); + } catch (error) { + throw new Error(`Windows feed bytes do not reference only the exact package for ${target}: ${error.message}`); + } + } + feeds[target] = { + target, + version: manifest.version, + feed: { url: feedUrl, size: feedBytes.length, sha256: checksumBytes(feedBytes) }, + artifact: { + url: artifactUrl, + fileName: artifact.fileName, + kind: updateKind, + size: artifact.size, + sha256: artifact.sha256, + }, + signer, + }; + } + return { feeds, feedFiles }; +}; + +export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, version, env = process.env }) => { + if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const unsignedManifest = JSON.parse(await readFile(join(inputDirectory, 'desktop-release.json'), 'utf8')); + if ( + unsignedManifest.schemaVersion !== 2 + || unsignedManifest.version !== version + || unsignedManifest.tag !== `desktop-v${version}` + || Object.keys(unsignedManifest.feeds ?? {}).length !== 0 + || !Array.isArray(unsignedManifest.artifacts) + ) { + throw new Error('Unsigned release metadata is invalid'); + } + for (const artifact of unsignedManifest.artifacts) { + const path = join(inputDirectory, artifact.fileName); + if (basename(artifact.fileName) !== artifact.fileName + || await checksum(path) !== artifact.sha256 + || (await stat(path)).size !== artifact.size) { + throw new Error(`Unsigned release artifact integrity is invalid: ${artifact.fileName}`); + } + } + + const configurationNames = [ + 'PROPR_DESKTOP_UPDATE_PRIVATE_KEY', + 'PROPR_DESKTOP_UPDATE_PUBLIC_KEY', + 'PROPR_DESKTOP_UPDATE_MANIFEST_URL', + 'PROPR_DESKTOP_MAC_TEAM_ID', + 'PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY', + 'PROPR_DESKTOP_WINDOWS_SIGNER_PINS', + ...configuredFeedDefinitions.map(([, name]) => name), + ]; + const present = configurationNames.filter(name => env[name]?.trim()); + if (present.length !== configurationNames.length) { + throw new Error(`Trusted update signing configuration is incomplete; missing ${configurationNames.filter(name => !env[name]?.trim()).join(', ')}`); + } + const windowsSignerPins = parseWindowsSignerPins(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS); + + for (const target of ['darwin-x64', 'darwin-arm64']) { + const signer = readNativeSigner('darwin', { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: unsignedManifest.nativeSigners?.[target]?.type, + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: unsignedManifest.nativeSigners?.[target]?.identity, + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: unsignedManifest.nativeSigners?.[target]?.designatedRequirement, + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: unsignedManifest.nativeSigners?.[target]?.certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: unsignedManifest.nativeSigners?.[target]?.spkiSha256, + }); + if (!signer || signer.identity !== env.PROPR_DESKTOP_MAC_TEAM_ID.trim()) { + throw new Error(`Actual native signer mismatch for ${target}`); + } + } + const windowsSigners = []; + for (const target of ['win32-x64', 'win32-arm64']) { + const signer = readNativeSigner('win32', { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: unsignedManifest.nativeSigners?.[target]?.type, + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: unsignedManifest.nativeSigners?.[target]?.identity, + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: unsignedManifest.nativeSigners?.[target]?.designatedRequirement, + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: unsignedManifest.nativeSigners?.[target]?.certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: unsignedManifest.nativeSigners?.[target]?.spkiSha256, + }); + if (!signer || signer.identity !== env.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY.trim() + || !windowsSignerMatchesPins(signer, windowsSignerPins)) { + throw new Error(`Actual native signer mismatch for ${target}`); + } + windowsSigners.push(signer); + } + if (JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1])) { + throw new Error('Windows release targets contain mixed native signer evidence'); + } + + const manifestUrl = parseHttpsUrl( + env.PROPR_DESKTOP_UPDATE_MANIFEST_URL.trim(), + 'PROPR_DESKTOP_UPDATE_MANIFEST_URL', + { allowQuery: false }, + ); + const privateKey = createPrivateKey({ + key: Buffer.from(env.PROPR_DESKTOP_UPDATE_PRIVATE_KEY.trim(), 'base64'), + format: 'der', + type: 'pkcs8', + }); + if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('Update signing private key must be Ed25519'); + const actualPublicKey = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).toString('base64'); + if (actualPublicKey !== env.PROPR_DESKTOP_UPDATE_PUBLIC_KEY.trim()) { + throw new Error('Update signing private and public keys do not match'); + } + + await rm(outputDirectory, { recursive: true, force: true }); + await cp(inputDirectory, outputDirectory, { recursive: true }); + const { feeds, feedFiles } = await createSignedFeeds(unsignedManifest, outputDirectory, env); + const signedManifest = { ...unsignedManifest, manifestUrl, windowsSignerPins, feeds }; + const manifestPayload = Buffer.from(`${JSON.stringify(signedManifest, null, 2)}\n`); + const signaturePayload = Buffer.from(`${sign(null, manifestPayload, privateKey).toString('base64')}\n`); + await writeFile(join(outputDirectory, 'desktop-release.json'), manifestPayload); + await writeFile(join(outputDirectory, 'desktop-release.json.sig'), signaturePayload); + await writeFile( + join(outputDirectory, 'SHA256SUMS'), + `${[ + ...unsignedManifest.artifacts, + ...feedFiles, + { fileName: 'desktop-release.json', size: manifestPayload.length, sha256: checksumBytes(manifestPayload) }, + { fileName: 'desktop-release.json.sig', size: signaturePayload.length, sha256: checksumBytes(signaturePayload) }, + ].sort((left, right) => left.fileName.localeCompare(right.fileName)) + .map(file => `${file.sha256} ${file.fileName}`) + .join('\n')}\n`, + ); + return signedManifest; +}; + +const argument = name => { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const command = process.argv[2]; + if (command === 'probe-dmg-private-snapshot-isolation') { + const makeDirectory = argument('--make-directory'); + const arch = argument('--arch'); + const version = argument('--version'); + if (!makeDirectory || !arch || !version) { + throw new Error('Private-snapshot DMG isolation probe requires --make-directory, --arch, and --version'); + } + const result = await probePrivateDmgSnapshotIsolation({ + makeDirectory: resolve(makeDirectory), + arch, + version, + }); + console.log(JSON.stringify({ privateSnapshotDmgIsolation: true, architecture: arch, ...result })); + } else if (command === 'stage') { + const version = argument('--version'); + if (!version) throw new Error('--version is required'); + await stageArtifacts({ + makeDirectory: resolve(argument('--make-directory') || 'out/make'), + outputDirectory: resolve(argument('--output') || 'release-staging'), + platform: argument('--platform') || process.platform, + arch: argument('--arch') || process.arch, + version, + }); + } else if (command === 'finalize') { + const version = argument('--version'); + if (!version) throw new Error('--version is required'); + await finalizeArtifacts({ + inputDirectory: resolve(argument('--input') || 'release-artifacts'), + outputDirectory: resolve(argument('--output') || 'release-final'), + version, + }); + } else if (command === 'sign') { + const version = argument('--version'); + if (!version) throw new Error('--version is required'); + await signReleaseMetadata({ + inputDirectory: resolve(argument('--input') || 'release-final'), + outputDirectory: resolve(argument('--output') || 'release-signed'), + version, + }); + } else { + throw new Error('Expected release-artifacts.mjs private-snapshot probe, stage, finalize, or sign command'); + } +} diff --git a/apps/desktop/scripts/release-artifacts.test.mjs b/apps/desktop/scripts/release-artifacts.test.mjs new file mode 100644 index 000000000..a839a8497 --- /dev/null +++ b/apps/desktop/scripts/release-artifacts.test.mjs @@ -0,0 +1,1249 @@ +import assert from 'node:assert/strict'; +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash, generateKeyPairSync, verify } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { access, chmod, link, lstat, mkdtemp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, test } from 'node:test'; +import { promisify } from 'node:util'; +import { + finalizeArtifacts, + parseSquirrelReleases, + signReleaseMetadata, + stageArtifacts, + validateSquirrelReleases, +} from './release-artifacts.mjs'; +import { + createHeldDmgArtifact, + inspectArtifactArchitecture, + inspectExecutableBytes, + readHeldDmgArtifactBytes, +} from './release-architecture.mjs'; + +const kinds = { + 'linux-x64': ['deb', 'rpm', 'zip'], + 'linux-arm64': ['deb', 'rpm', 'zip'], + 'darwin-x64': ['dmg', 'zip'], + 'darwin-arm64': ['dmg', 'zip'], + 'win32-x64': ['setup', 'msi', 'nupkg', 'releases'], + 'win32-arm64': ['setup', 'msi', 'nupkg', 'releases'], +}; + +const sourceName = kind => kind === 'setup' ? 'Desktop Setup.exe' + : kind === 'msi' ? 'Desktop-Machine-Setup.msi' + : kind === 'nupkg' ? 'desktop-1.2.3-full.nupkg' : kind === 'releases' ? 'RELEASES' : `desktop.${kind}`; +const certificateSha256 = '1'.repeat(64); +const spkiSha256 = '2'.repeat(64); +const windowsSignerPins = `certificate-sha256:${certificateSha256},spki-sha256:${spkiSha256}`; +const execFile = promisify(execFileCallback); +const nativeDarwinArch = process.arch === 'arm64' ? 'arm64' : 'x64'; +const compilerInputEvidence = (name, sha256, architecture = 'x64') => ({ + name, + size: 1, + sha256, + signerCertificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + signerSpkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + signerRootSpkiSha256: '3'.repeat(64), + catalogName: architecture === 'arm64' + ? 'Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat' + : 'Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat', + catalogSha256: architecture === 'arm64' + ? 'fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85' + : 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef', + catalogVolumeSerial: '5'.repeat(16), + catalogFileId128: '6'.repeat(32), +}); + +const privateDmgSnapshotPaths = async () => { + const entries = await readdir(tmpdir(), { withFileTypes: true }); + const paths = []; + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('propr-dmg-snapshot-')) continue; + const directory = join(tmpdir(), entry.name); + for (const name of await readdir(directory)) { + if (name.endsWith('.dmg')) paths.push(join(directory, name)); + } + } + return paths; +}; + +const findNewPrivateDmgSnapshot = async previous => { + const paths = (await privateDmgSnapshotPaths()).filter(path => !previous.has(path)); + assert.equal(paths.length, 1, 'inspection must create exactly one private DMG snapshot'); + return paths[0]; +}; + +const nativeDmgValidation = arch => ({ + schemaVersion: 1, + tool: 'propr-desktop-release-architecture', + toolVersion: '1.0.0', + nativePlatform: 'darwin', + mountMethod: 'hdiutil-attach-readonly', + layout: { + topLevelApplication: 'propr-desktop.app', + installLink: { path: 'Applications', type: 'symbolic-link', target: '/Applications' }, + mainExecutable: { + path: 'propr-desktop.app/Contents/MacOS/propr-desktop', + format: 'mach-o', + architectures: [arch], + }, + helperExecutables: [ + 'propr-desktop Helper.app', + 'propr-desktop Helper (GPU).app', + 'propr-desktop Helper (Plugin).app', + 'propr-desktop Helper (Renderer).app', + ].map(bundle => ({ + bundle, + path: `propr-desktop.app/Contents/Frameworks/${bundle}/Contents/MacOS/${bundle.slice(0, -'.app'.length)}`, + format: 'mach-o', + architectures: [arch], + })), + }, +}); + +const architectureInspector = async ({ path, heldArtifact, kind, platform, arch }) => { + if (kind === 'releases') return { format: 'squirrel-releases', target: `${platform}-${arch}` }; + const contents = kind === 'dmg' + ? (await readHeldDmgArtifactBytes(heldArtifact)).toString('utf8') + : await readFile(path, 'utf8'); + if (!contents.includes(`${platform}-${arch}-${kind}`)) { + throw new Error(`${kind} packaged executable architecture mismatch for ${platform}-${arch}`); + } + return { + format: kind, + executable: { platform, architectures: [arch] }, + ...(kind === 'dmg' ? { nativeValidation: nativeDmgValidation(arch) } : {}), + }; +}; + +const windowsDmgFixtureAuthority = Object.freeze({ + schemaVersion: 1, + platform: 'win32', + scope: 'release-test-private-dmg', +}); + +const stageFixtureArtifacts = arguments_ => stageArtifacts({ + ...arguments_, + privateDmgFixtureAuthority: windowsDmgFixtureAuthority, +}); + +const signerEnvironment = platform => platform === 'darwin' + ? { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: 'apple-team-id', + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: 'TEAM123456', + PROPR_DESKTOP_ACTUAL_MAC_DESIGNATED_REQUIREMENT: 'designated => identifier "dev.propr.desktop" and anchor apple generic', + } + : platform === 'win32' + ? { + PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: 'authenticode-subject', + PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: 'CN=Example Publisher', + PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256: certificateSha256, + PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256: spkiSha256, + } + : {}; + +const createFragments = async (root, { signed = false } = {}) => { + const fragments = join(root, 'fragments'); + for (const [target, targetKinds] of Object.entries(kinds)) { + const [platform, arch] = target.split('-'); + const makeDirectory = join(root, 'make', target); + await mkdir(makeDirectory, { recursive: true }); + const nupkgContents = `${target}-nupkg`; + for (const kind of targetKinds) { + const contents = kind === 'releases' + ? `${createHash('sha1').update(nupkgContents).digest('hex')} desktop-1.2.3-full.nupkg ${Buffer.byteLength(nupkgContents)}\n` + : kind === 'nupkg' ? nupkgContents : `${target}-${kind}`; + await writeFile(join(makeDirectory, sourceName(kind)), contents); + } + await stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(fragments, target), + platform, + arch, + version: '1.2.3', + env: signed ? signerEnvironment(platform) : {}, + inspectArchitecture: architectureInspector, + }); + } + return fragments; +}; + +const signingEnvironment = keys => ({ + PROPR_DESKTOP_UPDATE_PRIVATE_KEY: keys.privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64'), + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: keys.publicKey.export({ format: 'der', type: 'spki' }).toString('base64'), + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_MAC_TEAM_ID: 'TEAM123456', + PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY: 'CN=Example Publisher', + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: windowsSignerPins, + PROPR_DESKTOP_DARWIN_X64_FEED_URL: 'https://updates.example.test/darwin/x64/RELEASES.json', + PROPR_DESKTOP_DARWIN_ARM64_FEED_URL: 'https://updates.example.test/darwin/arm64/RELEASES.json', + PROPR_DESKTOP_WINDOWS_X64_FEED_URL: 'https://updates.example.test/win32/x64/', + PROPR_DESKTOP_WINDOWS_ARM64_FEED_URL: 'https://updates.example.test/win32/arm64/', +}); + +const peFixture = machine => { + const bytes = Buffer.alloc(128); + bytes.write('MZ'); + bytes.writeUInt32LE(64, 0x3c); + bytes.writeUInt32LE(0x00004550, 64); + bytes.writeUInt16LE(machine, 68); + return bytes; +}; + +const windowsAuthorityFixtureEntries = (executablePath, executable) => { + const helper = Buffer.alloc(1024); + helper.writeUInt16LE(0x5a4d, 0); + helper.writeUInt32LE(0x80, 0x3c); + helper.write('PE\0\0', 0x80, 'ascii'); + helper.writeUInt16LE(0x14c, 0x84); + helper.writeUInt16LE(1, 0x86); + helper.writeUInt16LE(224, 0x94); + helper.writeUInt16LE(0x10b, 0x98); + helper.writeUInt32LE(0x2000, 0x98 + 96 + (14 * 8)); + helper.writeUInt32LE(72, 0x98 + 96 + (14 * 8) + 4); + helper.writeUInt32LE(0x200, 0x178 + 8); + helper.writeUInt32LE(0x2000, 0x178 + 12); + helper.writeUInt32LE(0x200, 0x178 + 16); + helper.writeUInt32LE(0x200, 0x178 + 20); + helper.writeUInt32LE(0x1, 0x210); + const executablePe = executable.length >= 64 && executable.readUInt16LE(0) === 0x5a4d + ? executable.readUInt32LE(0x3c) : -1; + const launcherMachine = executablePe >= 0 && executablePe + 6 <= executable.length + ? executable.readUInt16LE(executablePe + 4) : 0x8664; + const launcherArchitecture = launcherMachine === 0xaa64 ? 'arm64' : 'x64'; + const launcher = Buffer.from(helper); + launcher.writeUInt16LE(launcherMachine, 0x84); + const manifest = Buffer.from(`${JSON.stringify({ + schemaVersion: 1, + name: 'propr-windows-authority.exe', + format: 'PE32', + architecture: 'anycpu', + machine: 'I386', + clr: true, + size: helper.length, + sha256: createHash('sha256').update(helper).digest('hex'), + sourceSha256: 'a'.repeat(64), + protocol: 'propr-windows-authority-v1', + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + launcher: { + name: 'propr-windows-launcher.node', + format: 'PE', + architecture: launcherArchitecture, + machine: launcherArchitecture === 'arm64' ? 'ARM64' : 'AMD64', + size: launcher.length, + sha256: createHash('sha256').update(launcher).digest('hex'), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + bootstrap: { + name: 'propr-windows-bootstrap.node', + format: 'PE', + architecture: launcherArchitecture, + machine: launcherArchitecture === 'arm64' ? 'ARM64' : 'AMD64', + size: launcher.length, + sha256: createHash('sha256').update(launcher).digest('hex'), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + compiler: { + kind: 'windows-catalog-authorized-dotnet-framework-csc-v1', + framework: 'Framework64-v4.0.30319', + signerCertificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + signerSpkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + signerRootSpkiSha256: '3'.repeat(64), + volumeSerial: '4'.repeat(16), + fileId128: '5'.repeat(32), + inputs: [ + compilerInputEvidence('csc.exe', 'b'.repeat(64), launcherArchitecture), + compilerInputEvidence('System.dll', 'c'.repeat(64), launcherArchitecture), + compilerInputEvidence('System.Web.Extensions.dll', 'd'.repeat(64), launcherArchitecture), + ], + }, + })}\n`); + return [ + [executablePath, executable], + ['lib/net45/resources/windows-authority/propr-windows-authority.exe', helper], + ['lib/net45/resources/windows-authority/propr-windows-authority.manifest.json', manifest], + ['lib/net45/resources/windows-authority/propr-windows-launcher.node', launcher], + ['lib/net45/resources/windows-authority/propr-windows-bootstrap.node', launcher], + ]; +}; + +const machOFixture = cpuType => { + const bytes = Buffer.alloc(32); + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(cpuType, 4); + return bytes; +}; + +const crcTable = Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + return crc >>> 0; +}); +const crc32 = bytes => { + let crc = 0xffffffff; + for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +}; + +const storedZip = entries => { + const localParts = []; + const centralParts = []; + let offset = 0; + for (const [name, contents, unixMode = 0] of entries) { + const nameBytes = Buffer.from(name); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(contents), 14); + local.writeUInt32LE(contents.length, 18); + local.writeUInt32LE(contents.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + localParts.push(local, nameBytes, contents); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(contents), 16); + central.writeUInt32LE(contents.length, 20); + central.writeUInt32LE(contents.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(((unixMode & 0xffff) << 16) >>> 0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBytes); + offset += local.length + nameBytes.length + contents.length; + } + const centralDirectory = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...localParts, centralDirectory, end]); +}; + +describe('desktop release artifacts', () => { + test('stages named artifacts and finalizes unsigned validation metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-test-')); + const fragments = await createFragments(root); + const output = join(root, 'final'); + const manifest = await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: output, version: '1.2.3', inspectArchitecture: architectureInspector }); + assert.equal(manifest.schemaVersion, 2); + assert.equal(manifest.artifacts.length, 18); + assert.equal(manifest.tag, 'desktop-v1.2.3'); + assert.equal(Object.keys(manifest.feeds).length, 0); + assert.equal(Object.keys(manifest.nativeSigners).length, 0); + await assert.rejects(access(join(output, 'desktop-release.json.sig'))); + const checksumLines = (await readFile(join(output, 'SHA256SUMS'), 'utf8')).trim().split('\n'); + assert.equal(checksumLines.length, 18); + assert.ok(checksumLines.some(line => line.endsWith('ProPR-Desktop-1.2.3-windows-x64-Setup.exe'))); + for (const line of checksumLines) { + const match = /^([a-f0-9]{64}) ([^/\\]+)$/.exec(line); + assert.ok(match, `invalid SHA256SUMS line: ${line}`); + assert.equal(createHash('sha256').update(await readFile(join(output, match[2]))).digest('hex'), match[1]); + } + assert.match( + await readFile(join(output, 'ProPR-Desktop-1.2.3-windows-x64-RELEASES'), 'utf8'), + /ProPR-Desktop-1\.2\.3-windows-x64-full\.nupkg/, + ); + const dmg = manifest.artifacts.find(artifact => artifact.kind === 'dmg' && artifact.arch === 'arm64'); + assert.deepEqual(dmg.nativeDmgValidationEvidence.artifact, { + fileName: dmg.fileName, + size: dmg.size, + sha256: dmg.sha256, + }); + assert.equal(dmg.nativeDmgValidationEvidence.validatedNatively, true); + assert.deepEqual(dmg.nativeDmgValidationEvidence.layout.installLink, { + path: 'Applications', + type: 'symbolic-link', + target: '/Applications', + }); + }); + + test('rejects altered DMG bytes even when fragment artifact metadata is rewritten', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-altered-')); + const fragments = await createFragments(root); + const fragmentPath = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + const dmg = fragment.artifacts.find(artifact => artifact.kind === 'dmg'); + const dmgPath = join(fragments, 'darwin-arm64', dmg.fileName); + const altered = Buffer.from('darwin-arm64-dmg-altered-after-native-validation'); + await writeFile(dmgPath, altered); + dmg.size = altered.length; + dmg.sha256 = createHash('sha256').update(altered).digest('hex'); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /does not bind the exact canonical DMG bytes/, + ); + }); + + test('rejects permanent DMG replacement or in-place mutation during held inspection without emitting evidence', async () => { + for (const operation of ['in-place-mutation', 'permanent-replace']) { + const root = await mkdtemp(join(tmpdir(), `propr-release-dmg-inspection-${operation}-`)); + const makeDirectory = join(root, 'make'); + const outputDirectory = join(root, 'stage'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), 'darwin-arm64-dmg'); + await writeFile(join(makeDirectory, 'desktop.zip'), 'darwin-arm64-zip'); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch: 'arm64', + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + assert.equal(arguments_.path, undefined, 'DMG inspectors must not receive a mutable pathname'); + assert.deepEqual(Object.keys(arguments_.heldArtifact), ['description']); + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + assert.ok(!privatePath.startsWith(`${outputDirectory}/`), 'private snapshot must stay outside public output'); + if (operation === 'in-place-mutation') { + await writeFile(privatePath, 'darwin-arm64-dmg-mutated-during-native-validation'); + } else { + const displaced = `${privatePath}.displaced`; + await rename(privatePath, displaced); + await writeFile(privatePath, 'darwin-arm64-dmg-permanent-replacement'); + } + } + return inspection; + }, + }), + /Staged DMG identity or content changed during native validation|pathname no longer names the held exact artifact|\[dmg-private:file-(?:identity|mode)\]/, + operation, + ); + await assert.rejects(access(join(outputDirectory, 'release-fragment.json')), undefined, operation); + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps held A bytes, evidence, and publication stable when original and public pathnames change during inspection', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-swap-restore-')); + const makeDirectory = join(root, 'make'); + const outputDirectory = join(root, 'stage'); + await mkdir(makeDirectory); + const originalPath = join(makeDirectory, 'desktop.dmg'); + const destination = join(outputDirectory, 'ProPR-Desktop-1.2.3-macos-arm64-dmg'); + await writeFile(originalPath, 'darwin-arm64-dmg-A'); + await writeFile(join(makeDirectory, 'desktop.zip'), 'darwin-arm64-zip'); + const expectedBytes = Buffer.from('darwin-arm64-dmg-A'); + const fragment = await stageFixtureArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch: 'arm64', + version: '1.2.3', + inspectArchitecture: async arguments_ => { + if (arguments_.kind !== 'dmg') return architectureInspector(arguments_); + assert.equal(arguments_.path, undefined, 'the mutable private pathname must not enter the callback API'); + assert.deepEqual(Object.keys(arguments_.heldArtifact), ['description']); + assert.deepEqual(await readHeldDmgArtifactBytes(arguments_.heldArtifact), expectedBytes); + const displaced = `${originalPath}.held-A`; + await rename(originalPath, displaced); + await writeFile(originalPath, 'darwin-arm64-dmg-B'); + await writeFile(destination, 'attacker-controlled-public-B'); + assert.equal(await readFile(destination, 'utf8'), 'attacker-controlled-public-B'); + await rm(destination); + return architectureInspector(arguments_); + }, + }); + const artifact = fragment.artifacts.find(candidate => candidate.kind === 'dmg'); + assert.equal(artifact.sha256, createHash('sha256').update(expectedBytes).digest('hex')); + assert.equal(artifact.nativeDmgValidationEvidence.artifact.sha256, artifact.sha256); + assert.ok(!JSON.stringify(fragment).includes('propr-dmg-snapshot-'), 'private snapshot path must not enter evidence'); + assert.deepEqual(await readFile(destination), expectedBytes); + await rm(root, { recursive: true, force: true }); + }); + + test('continues to reject a mutable pathname passed directly to DMG inspection', async () => { + await assert.rejects( + inspectArtifactArchitecture({ path: '/tmp/public.dmg', kind: 'dmg', platform: 'darwin', arch: 'arm64' }), + /DMG inspection rejects mutable pathnames/, + ); + }); + + test('requires explicit fixture authority for Windows-hosted DMG evidence tests', { + skip: process.platform !== 'win32', + }, async () => { + await assert.rejects( + stageArtifacts({ + makeDirectory: 'unused', + outputDirectory: 'unused', + platform: 'darwin', + arch: 'x64', + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /explicit scoped fixture authority/, + ); + }); + + test('accepts real Darwin mode-0700 directory and mode-0600 single-link file authority', { + skip: process.platform !== 'darwin', + }, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-private-accept-')); + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), `darwin-${nativeDarwinArch}-dmg`); + await writeFile(join(makeDirectory, 'desktop.zip'), `darwin-${nativeDarwinArch}-zip`); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + try { + await stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'darwin', + arch: nativeDarwinArch, + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + const directoryStats = await lstat(dirname(privatePath), { bigint: true }); + const fileStats = await lstat(privatePath, { bigint: true }); + assert.equal(directoryStats.mode & 0o777n, 0o700n); + assert.equal(fileStats.mode & 0o777n, 0o600n); + assert.equal(fileStats.nlink, 1n); + } + return inspection; + }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects native Darwin broad mode, foreign owner, extra link, replacement type, and symlink with fixed authority codes', { + skip: process.platform !== 'darwin', + }, async t => { + const cases = [ + ['broad-mode', 'file-mode'], + ['foreign-owner', 'file-owner'], + ['hardlink', 'file-link'], + ['directory', 'file-type'], + ['symlink', 'file-symlink'], + ]; + for (const [scenario, code] of cases) await t.test(scenario, async () => { + const root = await mkdtemp(join(tmpdir(), `propr-release-dmg-private-${scenario}-`)); + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), `darwin-${nativeDarwinArch}-dmg`); + await writeFile(join(makeDirectory, 'desktop.zip'), `darwin-${nativeDarwinArch}-zip`); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'darwin', + arch: nativeDarwinArch, + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + if (scenario === 'broad-mode') await chmod(privatePath, 0o644); + else if (scenario === 'foreign-owner') await execFile('/usr/bin/sudo', ['-n', 'chown', '0', privatePath]); + else if (scenario === 'hardlink') await link(privatePath, `${privatePath}.link`); + else { + const displaced = `${privatePath}.displaced`; + await rename(privatePath, displaced); + if (scenario === 'directory') await mkdir(privatePath, { mode: 0o700 }); + else await symlink(displaced, privatePath); + } + } + return inspection; + }, + }), + error => error instanceof Error + && error.message === `Private DMG authority rejected [dmg-private:${code}]`, + ); + await rm(root, { recursive: true, force: true }); + }); + }); + + test('accepts native xattr/ctime-only change when held bytes and identity are unchanged', { + skip: process.platform !== 'darwin', + }, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-xattr-')); + const makeDirectory = join(root, 'make'); + const outputDirectory = join(root, 'stage'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), `darwin-${nativeDarwinArch}-dmg`); + await writeFile(join(makeDirectory, 'desktop.zip'), `darwin-${nativeDarwinArch}-zip`); + const previousSnapshots = new Set(await privateDmgSnapshotPaths()); + const fragment = await stageFixtureArtifacts({ + makeDirectory, + outputDirectory, + platform: 'darwin', + arch: nativeDarwinArch, + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + if (arguments_.kind === 'dmg') { + const privatePath = await findNewPrivateDmgSnapshot(previousSnapshots); + const before = await lstat(privatePath, { bigint: true }); + await execFile('xattr', ['-w', 'com.propr.descriptor-validation', 'verified', privatePath]); + const after = await lstat(privatePath, { bigint: true }); + assert.notEqual(after.ctimeNs, before.ctimeNs, 'fixture must exercise an xattr-only ctime change'); + } + return inspection; + }, + }); + assert.equal(fragment.artifacts.find(artifact => artifact.kind === 'dmg').nativeDmgValidationEvidence.validatedNatively, true); + await rm(root, { recursive: true, force: true }); + }); + + test('does not emit claimed DMG layout evidence without the native-validation marker', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-no-native-marker-')); + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory); + await writeFile(join(makeDirectory, 'desktop.dmg'), 'darwin-arm64-dmg'); + await writeFile(join(makeDirectory, 'desktop.zip'), 'darwin-arm64-zip'); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'darwin', + arch: 'arm64', + version: '1.2.3', + inspectArchitecture: async arguments_ => { + const inspection = await architectureInspector(arguments_); + delete inspection.nativeValidation; + return inspection; + }, + }), + /Native DMG validation marker must be an object/, + ); + }); + + test('strictly rejects missing, mixed, stale, malformed, or fabricated native DMG evidence', async () => { + const cases = [ + ['missing evidence', artifact => { delete artifact.nativeDmgValidationEvidence; }, /must be an object/], + ['wrong filename', artifact => { artifact.nativeDmgValidationEvidence.artifact.fileName = 'foreign.dmg'; }, /exact canonical DMG bytes/], + ['wrong version', artifact => { artifact.nativeDmgValidationEvidence.version = '1.2.4'; }, /mixed, stale, or cross-target/], + ['wrong target', artifact => { artifact.nativeDmgValidationEvidence.target = 'darwin-x64'; }, /mixed, stale, or cross-target/], + ['wrong architecture', artifact => { artifact.nativeDmgValidationEvidence.architecture = 'x64'; }, /mixed, stale, or cross-target/], + ['wrong hash', artifact => { artifact.nativeDmgValidationEvidence.artifact.sha256 = '0'.repeat(64); }, /exact canonical DMG bytes/], + ['wrong size', artifact => { artifact.nativeDmgValidationEvidence.artifact.size += 1; }, /exact canonical DMG bytes/], + ['wrong size type', artifact => { artifact.nativeDmgValidationEvidence.artifact.size = `${artifact.size}`; }, /exact canonical DMG bytes/], + ['missing layout field', artifact => { delete artifact.nativeDmgValidationEvidence.layout.mainExecutable; }, /missing or unknown keys/], + ['unknown layout key', artifact => { artifact.nativeDmgValidationEvidence.layout.untrusted = true; }, /missing or unknown keys/], + ['unknown record key', artifact => { artifact.nativeDmgValidationEvidence.untrusted = true; }, /missing or unknown keys/], + ['unknown schema', artifact => { artifact.nativeDmgValidationEvidence.schemaVersion = 2; }, /unsupported schemaVersion/], + ['symlink claim without native marker', artifact => { artifact.nativeDmgValidationEvidence.validatedNatively = false; }, /lacks the native-validation marker/], + ]; + for (const [name, mutate, expected] of cases) { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-evidence-')); + const fragments = await createFragments(root); + const fragmentPath = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + const artifact = fragment.artifacts.find(candidate => candidate.kind === 'dmg'); + mutate(artifact); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + expected, + name, + ); + } + }); + + test('rejects native DMG evidence copied between x64 and arm64 fragments', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-dmg-cross-label-')); + const fragments = await createFragments(root); + const x64Fragment = JSON.parse(await readFile(join(fragments, 'darwin-x64', 'release-fragment.json'), 'utf8')); + const arm64Path = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const arm64Fragment = JSON.parse(await readFile(arm64Path, 'utf8')); + arm64Fragment.artifacts.find(artifact => artifact.kind === 'dmg').nativeDmgValidationEvidence = + x64Fragment.artifacts.find(artifact => artifact.kind === 'dmg').nativeDmgValidationEvidence; + await writeFile(arm64Path, `${JSON.stringify(arm64Fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /mixed, stale, or cross-target|exact canonical DMG bytes/, + ); + }); + + test('rejects duplicate target fragments before aggregation', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-duplicate-fragment-')); + const fragments = await createFragments(root); + const duplicate = join(fragments, 'duplicate'); + await mkdir(duplicate); + await writeFile( + join(duplicate, 'release-fragment.json'), + await readFile(join(fragments, 'darwin-x64', 'release-fragment.json')), + ); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /Expected 6 release fragments, found 7/, + ); + }); + + test('parses every exact Squirrel RELEASES record and verifies SHA-1 and decimal size', () => { + const bytes = Buffer.from('exact nupkg bytes'); + const fileName = 'ProPR-Desktop-1.2.3-windows-x64-full.nupkg'; + const hash = createHash('sha1').update(bytes).digest('hex'); + for (const ending of ['\n', '\r\n']) { + const releases = Buffer.from(`${hash} ${fileName} ${bytes.length}${ending}`); + assert.deepEqual(validateSquirrelReleases(releases, [{ fileName, bytes }]).records, [ + { sha1: hash, fileName, size: bytes.length }, + ]); + } + assert.equal(parseSquirrelReleases(Buffer.from(`${hash.toUpperCase()} ${fileName} ${bytes.length}`)).records[0].sha1, hash); + }); + + test('rejects wrong Squirrel hash, size, duplicate, extra, missing, path, case, delta, and malformed lines', () => { + const bytes = Buffer.from('exact nupkg bytes'); + const fileName = 'ProPR-Desktop-1.2.3-windows-x64-full.nupkg'; + const hash = createHash('sha1').update(bytes).digest('hex'); + const record = `${hash} ${fileName} ${bytes.length}`; + const invalid = [ + `${'0'.repeat(40)} ${fileName} ${bytes.length}`, + `${hash} ${fileName} ${bytes.length + 1}`, + `${record}\n${record}`, + `${record}\n${hash} foreign-full.nupkg ${bytes.length}`, + '', + `${hash} path/${fileName} ${bytes.length}`, + `${hash} ${fileName.toUpperCase()} ${bytes.length}`, + `${hash} ProPR-Desktop-1.2.3-windows-x64-delta.nupkg ${bytes.length}`, + `${record}\n\n`, + `${hash} ${fileName} ${bytes.length}`, + ]; + for (const contents of invalid) { + assert.throws( + () => validateSquirrelReleases(Buffer.from(contents), [{ fileName, bytes }]), + /Squirrel RELEASES|Invalid Squirrel|does not contain|SHA-1 mismatch|size mismatch/, + ); + } + }); + + test('revalidates exact Squirrel package bytes during staging and aggregate finalization', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-squirrel-binding-')); + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory, { recursive: true }); + await writeFile(join(makeDirectory, 'Desktop Setup.exe'), 'win32-x64-setup'); + await writeFile(join(makeDirectory, 'Desktop-Machine-Setup.msi'), 'win32-x64-msi'); + await writeFile(join(makeDirectory, 'desktop-1.2.3-full.nupkg'), 'win32-x64-nupkg'); + await writeFile( + join(makeDirectory, 'RELEASES'), + `${'0'.repeat(40)} desktop-1.2.3-full.nupkg ${Buffer.byteLength('win32-x64-nupkg')}\n`, + ); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'win32', + arch: 'x64', + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /SHA-1 mismatch/, + ); + + const fragments = await createFragments(root); + const releasesPath = join(fragments, 'win32-x64', 'ProPR-Desktop-1.2.3-windows-x64-RELEASES'); + const valid = await readFile(releasesPath, 'utf8'); + const tamperedReleases = valid.replace(/^[a-f0-9]{40}/, 'f'.repeat(40)); + await writeFile(releasesPath, tamperedReleases); + const fragmentPath = join(fragments, 'win32-x64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + const releasesArtifact = fragment.artifacts.find(artifact => artifact.kind === 'releases'); + releasesArtifact.size = Buffer.byteLength(tamperedReleases); + releasesArtifact.sha256 = createHash('sha256').update(tamperedReleases).digest('hex'); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /invalid Squirrel RELEASES metadata.*SHA-1 mismatch/, + ); + }); + + test('fails closed when trusted update signing configuration is incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-missing-key-')); + const fragments = await createFragments(root, { signed: true }); + const unsigned = join(root, 'unsigned'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + const publicKey = generateKeyPairSync('ed25519').publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, 'signed'), + version: '1.2.3', + env: { PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey }, + }), + /configuration is incomplete.*PROPR_DESKTOP_UPDATE_PRIVATE_KEY/, + ); + const complete = signingEnvironment(generateKeyPairSync('ed25519')); + for (const name of Object.keys(complete)) { + const incomplete = { ...complete }; + delete incomplete[name]; + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, `missing-${name}`), + version: '1.2.3', + env: incomplete, + }), + new RegExp(`configuration is incomplete.*${name}`), + ); + } + }); + + test('signs cryptographically bound feeds only in the trusted release phase', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-sign-')); + const fragments = await createFragments(root, { signed: true }); + const unsigned = join(root, 'unsigned'); + const output = join(root, 'signed'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + const keys = generateKeyPairSync('ed25519'); + const manifest = await signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: output, + version: '1.2.3', + env: signingEnvironment(keys), + }); + + assert.equal(manifest.manifestUrl, 'https://updates.example.test/stable/desktop-release.json'); + assert.deepEqual(manifest.windowsSignerPins, windowsSignerPins.split(',')); + assert.deepEqual(Object.keys(manifest.feeds).sort(), [ + 'darwin-arm64', + 'darwin-x64', + 'win32-arm64', + 'win32-x64', + ]); + assert.equal(manifest.feeds['darwin-arm64'].signer.identity, 'TEAM123456'); + assert.equal(manifest.feeds['win32-x64'].signer.identity, 'CN=Example Publisher'); + assert.equal(manifest.feeds['win32-x64'].signer.certificateSha256, certificateSha256); + assert.equal(manifest.feeds['win32-x64'].signer.spkiSha256, spkiSha256); + assert.equal(manifest.feeds['win32-x64'].artifact.version, undefined); + assert.equal(manifest.feeds['win32-x64'].version, '1.2.3'); + const payload = await readFile(join(output, 'desktop-release.json')); + const signature = Buffer.from((await readFile(join(output, 'desktop-release.json.sig'), 'utf8')).trim(), 'base64'); + assert.equal(verify(null, payload, keys.publicKey, signature), true); + }); + + test('refuses to sign when artifact bytes changed after unsigned finalization', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-tamper-')); + const fragments = await createFragments(root, { signed: true }); + const unsigned = join(root, 'unsigned'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + await writeFile(join(unsigned, 'ProPR-Desktop-1.2.3-windows-x64-full.nupkg'), 'tampered'); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, 'signed'), + version: '1.2.3', + env: signingEnvironment(generateKeyPairSync('ed25519')), + }), + /artifact integrity is invalid/, + ); + }); + + test('rejects unsigned production metadata and actual signer mismatches', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-unsigned-production-')); + const fragments = await createFragments(root); + const unsigned = join(root, 'unsigned'); + await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: unsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: unsigned, + outputDirectory: join(root, 'signed'), + version: '1.2.3', + env: signingEnvironment(generateKeyPairSync('ed25519')), + }), + /Actual native signer mismatch/, + ); + + const signedFragments = await createFragments(await mkdtemp(join(tmpdir(), 'propr-release-signer-mismatch-')), { signed: true }); + const signedUnsigned = join(root, 'signed-unsigned'); + await finalizeArtifacts({ inputDirectory: signedFragments, outputDirectory: signedUnsigned, version: '1.2.3', inspectArchitecture: architectureInspector }); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: signedUnsigned, + outputDirectory: join(root, 'mismatch'), + version: '1.2.3', + env: { ...signingEnvironment(generateKeyPairSync('ed25519')), PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY: 'CN=Wrong Publisher' }, + }), + /Actual native signer mismatch for win32-x64/, + ); + + await assert.rejects( + signReleaseMetadata({ + inputDirectory: signedUnsigned, + outputDirectory: join(root, 'same-subject-different-key'), + version: '1.2.3', + env: { + ...signingEnvironment(generateKeyPairSync('ed25519')), + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: `certificate-sha256:${'3'.repeat(64)}`, + }, + }), + /Actual native signer mismatch for win32-x64/, + ); + await assert.rejects( + signReleaseMetadata({ + inputDirectory: signedUnsigned, + outputDirectory: join(root, 'malformed-pin'), + version: '1.2.3', + env: { + ...signingEnvironment(generateKeyPairSync('ed25519')), + PROPR_DESKTOP_WINDOWS_SIGNER_PINS: `certificate-sha256:${'A'.repeat(64)}`, + }, + }), + /canonical SHA-256 fingerprint allowlist/, + ); + }); + + test('rejects mixed Windows signers and tampered fingerprint evidence', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-mixed-signers-')); + const fragments = await createFragments(root, { signed: true }); + const fragmentPath = join(fragments, 'win32-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + fragment.nativeSigner.certificateSha256 = '3'.repeat(64); + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'final'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /mixed native signer evidence/, + ); + + fragment.nativeSigner.certificateSha256 = 'not-a-sha256'; + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'tampered'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /Native signer evidence is incomplete or invalid/, + ); + }); + + test('parses x64 and arm64 ELF, PE, and Mach-O executable fixtures', () => { + const elf = machine => { + const bytes = Buffer.alloc(64); + Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(bytes); + bytes[5] = 1; + bytes.writeUInt16LE(machine, 18); + return bytes; + }; + const pe = machine => { + const bytes = Buffer.alloc(128); + bytes.write('MZ'); + bytes.writeUInt32LE(64, 0x3c); + bytes.writeUInt32LE(0x00004550, 64); + bytes.writeUInt16LE(machine, 68); + return bytes; + }; + const machO = cpuType => { + const bytes = Buffer.alloc(32); + bytes.writeUInt32LE(0xfeedfacf, 0); + bytes.writeUInt32LE(cpuType, 4); + return bytes; + }; + assert.deepEqual(inspectExecutableBytes(elf(62)), { format: 'elf', architectures: ['x64'] }); + assert.deepEqual(inspectExecutableBytes(elf(183)), { format: 'elf', architectures: ['arm64'] }); + assert.deepEqual(inspectExecutableBytes(pe(0x8664)), { format: 'pe', architectures: ['x64'] }); + assert.deepEqual(inspectExecutableBytes(pe(0xaa64)), { format: 'pe', architectures: ['arm64'] }); + assert.deepEqual(inspectExecutableBytes(pe(0x014c)), { format: 'pe', architectures: ['x86'] }); + assert.deepEqual(inspectExecutableBytes(machO(0x01000007)), { format: 'mach-o', architectures: ['x64'] }); + assert.deepEqual(inspectExecutableBytes(machO(0x0100000c)), { format: 'mach-o', architectures: ['arm64'] }); + }); + + test('derives Windows target architecture from the full NUPKG independently of its supported bootstrapper', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-squirrel-arch-')); + const setup = join(root, 'Setup.exe'); + const arm64Package = join(root, 'desktop-arm64-full.nupkg'); + await writeFile(setup, peFixture(0x014c)); + await writeFile(arm64Package, storedZip(windowsAuthorityFixtureEntries( + 'lib/net45/propr-desktop.exe', peFixture(0xaa64), + ))); + + assert.deepEqual( + await inspectArtifactArchitecture({ path: setup, kind: 'setup', platform: 'win32', arch: 'arm64' }), + { format: 'squirrel-setup', executable: { format: 'pe', architectures: ['x86'] } }, + ); + assert.deepEqual( + await inspectArtifactArchitecture({ path: arm64Package, kind: 'nupkg', platform: 'win32', arch: 'arm64' }), + { format: 'nupkg', executable: { format: 'pe', architectures: ['arm64'] } }, + ); + + await assert.rejects( + inspectArtifactArchitecture({ path: arm64Package, kind: 'nupkg', platform: 'win32', arch: 'x64' }), + /executable architecture mismatch.*pe\/x64.*pe\/arm64/, + ); + await writeFile(arm64Package, storedZip(windowsAuthorityFixtureEntries( + 'lib/net45/propr-desktop.exe', Buffer.from('tampered payload'), + ))); + await assert.rejects( + inspectArtifactArchitecture({ path: arm64Package, kind: 'nupkg', platform: 'win32', arch: 'arm64' }), + /not a recognized.*binary/, + ); + await writeFile(setup, peFixture(0x01c0)); + await assert.rejects( + inspectArtifactArchitecture({ path: setup, kind: 'setup', platform: 'win32', arch: 'arm64' }), + /not a supported x86, x64, or arm64 Squirrel PE bootstrapper/, + ); + }); + + test('binds ZIP and NUPKG executables to exact maker-specific canonical paths', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-canonical-archives-')); + const fixtures = [ + ['linux.zip', 'zip', 'linux', 'x64', 'propr-desktop-linux-x64/propr-desktop', Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 1, ...Array(12).fill(0), 62, 0])], + ['darwin.zip', 'zip', 'darwin', 'arm64', 'propr-desktop.app/Contents/MacOS/propr-desktop', machOFixture(0x0100000c)], + ['windows.nupkg', 'nupkg', 'win32', 'x64', 'lib/net45/propr-desktop.exe', peFixture(0x8664)], + ]; + for (const [name, kind, platform, arch, executablePath, bytes] of fixtures) { + const path = join(root, name); + const entries = kind === 'nupkg' + ? windowsAuthorityFixtureEntries(executablePath, bytes) + : [[executablePath, bytes]]; + await writeFile(path, storedZip(entries)); + const result = await inspectArtifactArchitecture({ path, kind, platform, arch }); + assert.equal(result.executable.architectures[0], arch); + } + }); + + test('rejects missing, corrupt, mismatched, and ambiguous packaged Windows authority helpers', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-windows-authority-')); + const executablePath = 'lib/net45/propr-desktop.exe'; + const executable = peFixture(0x8664); + const exact = windowsAuthorityFixtureEntries(executablePath, executable); + const corruptManifest = exact.map(entry => [...entry]); + const parsed = JSON.parse(corruptManifest[2][1].toString('utf8')); + parsed.sha256 = '0'.repeat(64); + corruptManifest[2][1] = Buffer.from(`${JSON.stringify(parsed)}\n`); + const corruptHelper = exact.map(entry => [...entry]); + corruptHelper[1][1] = Buffer.from(corruptHelper[1][1]); + corruptHelper[1][1][0] = 0; + const cases = [ + ['missing', [exact[0]], /missing its exact Windows authority helper binding/], + ['manifest', corruptManifest, /does not match its bound manifest/], + ['output', corruptHelper, /does not match its bound manifest|not the expected managed/], + ['alternate', [...exact, ['tools/propr-windows-authority.exe', exact[1][1]]], /ambiguous Windows authority helper layout/], + ]; + for (const [name, entries, pattern] of cases) { + const path = join(root, `${name}.nupkg`); + await writeFile(path, storedZip(entries)); + await assert.rejects( + inspectArtifactArchitecture({ path, kind: 'nupkg', platform: 'win32', arch: 'x64' }), + pattern, + ); + } + }); + + test('accepts only the real Forge macOS framework-internal symbolic-link layout', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-darwin-framework-')); + context.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, 'darwin.zip'); + const framework = 'propr-desktop.app/Contents/Frameworks/Electron Framework.framework'; + const symlink = (name, target) => [`${framework}/${name}`, Buffer.from(target), 0xa1ff]; + await writeFile(path, storedZip([ + ['propr-desktop.app/Contents/MacOS/propr-desktop', machOFixture(0x0100000c)], + [`${framework}/Versions/A/Electron Framework`, machOFixture(0x0100000c)], + [`${framework}/Versions/A/Resources/Info.plist`, Buffer.from('resources')], + [`${framework}/Versions/A/Libraries/libEGL.dylib`, Buffer.from('library')], + [`${framework}/Versions/A/Helpers/chrome_crashpad_handler`, Buffer.from('helper')], + symlink('Versions/Current', 'A'), + symlink('Electron Framework', 'Versions/Current/Electron Framework'), + symlink('Resources', 'Versions/Current/Resources'), + symlink('Libraries', 'Versions/Current/Libraries'), + symlink('Helpers', 'Versions/Current/Helpers'), + ])); + + assert.deepEqual( + await inspectArtifactArchitecture({ path, kind: 'zip', platform: 'darwin', arch: 'arm64' }), + { format: 'zip', executable: { format: 'mach-o', architectures: ['arm64'] } }, + ); + }); + + test('rejects hostile macOS ZIP symbolic links before trusting their payloads', async context => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-hostile-darwin-links-')); + context.after(() => rm(root, { recursive: true, force: true })); + const executablePath = 'propr-desktop.app/Contents/MacOS/propr-desktop'; + const framework = 'propr-desktop.app/Contents/Frameworks/Electron Framework.framework'; + const executable = [executablePath, machOFixture(0x0100000c)]; + const target = [`${framework}/Versions/A/Resources/Info.plist`, Buffer.from('resource')]; + const link = (name, contents) => [`${framework}/${name}`, Buffer.isBuffer(contents) ? contents : Buffer.from(contents), 0xa1ff]; + const cases = [ + ['absolute', [executable, target, link('Resources', '/Applications')], /unsafe relative target/], + ['escaping', [executable, target, link('Resources', '../../../../MacOS')], /unsafe relative target/], + ['chained-escape', [ + executable, + target, + link('Resources', 'Versions/Current/Resources'), + link('Versions/Current', '../../../../../outside'), + ], /unsafe relative target/], + ['cycle', [executable, target, link('Resources', 'Libraries'), link('Libraries', 'Resources')], /contains a cycle/], + ['oversized', [executable, target, link('Resources', Buffer.alloc(1025, 0x61))], /oversized payload/], + ['malformed-utf8', [executable, target, link('Resources', Buffer.from([0xc3, 0x28]))], /cannot be decoded strictly/], + ['duplicate', [executable, target, link('Resources', 'Versions/A/Resources'), link('Resources', 'Versions/A/Resources')], /duplicate or case-colliding/], + ['missing', [executable, target, link('Resources', 'Versions/B/Resources')], /missing target/], + ['case-mismatched-target', [executable, target, link('Resources', 'Versions/a/Resources')], /missing target/], + ['canonical-executable', [ + [executablePath, Buffer.from('../Frameworks/Electron Framework.framework/Electron Framework'), 0xa1ff], + target, + ], /symbolic link outside canonical macOS framework internals/], + ['helper-executable', [ + executable, + target, + ['propr-desktop.app/Contents/Frameworks/propr-desktop Helper.app/Contents/MacOS/propr-desktop Helper', Buffer.from('target'), 0xa1ff], + ], /symbolic link outside canonical macOS framework internals/], + ['nested-helper-executable', [ + executable, + target, + [`${framework}/Helpers/propr-desktop Helper`, Buffer.from('Versions/A/Resources'), 0xa1ff], + ], /symbolic link outside canonical macOS framework internals/], + ['alternate-root', [executable, target, ['Other.app/Contents/Frameworks/Other.framework/Current', Buffer.from('A'), 0xa1ff]], /symbolic link outside canonical macOS framework internals/], + ['special-file', [executable, target, [`${framework}/special`, Buffer.from('special'), 0x11ff]], /symbolic link or special file/], + ]; + for (const [name, entries, pattern] of cases) { + const path = join(root, `${name}.zip`); + await writeFile(path, storedZip(entries)); + await assert.rejects( + inspectArtifactArchitecture({ path, kind: 'zip', platform: 'darwin', arch: 'arm64' }), + pattern, + name, + ); + } + + const crcPath = join(root, 'link-crc.zip'); + const linkName = `${framework}/Resources`; + const crcBytes = storedZip([executable, target, link('Resources', 'Versions/A/Resources')]); + const localLinkRecord = crcBytes.indexOf(Buffer.from(`${linkName}Versions/A/Resources`)); + assert.notEqual(localLinkRecord, -1); + const payloadOffset = localLinkRecord + Buffer.byteLength(linkName); + crcBytes[payloadOffset] ^= 1; + await writeFile(crcPath, crcBytes); + await assert.rejects( + inspectArtifactArchitecture({ path: crcPath, kind: 'zip', platform: 'darwin', arch: 'arm64' }), + /size or CRC is invalid/, + ); + }); + + test('rejects unsafe, duplicate, shadowed, forged, alternate, and noncanonical archive layouts', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-malicious-archives-')); + const executable = peFixture(0x8664); + const cases = [ + ['traversal', storedZip([['../lib/net45/propr-desktop.exe', executable]]), /non-normalized|unsafe name/], + ['duplicate', storedZip([['lib/net45/propr-desktop.exe', executable], ['lib/net45/propr-desktop.exe', executable]]), /duplicate or case-colliding/], + ['case', storedZip([['lib/net45/propr-desktop.exe', executable], ['LIB/NET45/PROPR-DESKTOP.EXE', executable]]), /case-colliding/], + ['shadow', storedZip([['lib', Buffer.from('file')], ['lib/net45/propr-desktop.exe', executable]]), /conflicting file and directory prefix/], + ['alternate', storedZip([['lib/net45/propr-desktop.exe', executable], ['tools/propr-desktop.exe', executable]]), /executable outside/], + ['wrong-path', storedZip([['lib/net46/propr-desktop.exe', executable]]), /executable outside|missing canonical/], + ]; + const valid = storedZip(windowsAuthorityFixtureEntries('lib/net45/propr-desktop.exe', executable)); + const forged = Buffer.from(valid); + const localNameOffset = 30; + Buffer.from('lib/net46/propr-desktop.exe').copy(forged, localNameOffset); + cases.push(['forged-local-header', forged, /central and local entry metadata disagree/]); + cases.push(['trailing-ambiguity', Buffer.concat([valid, Buffer.from('trailing')]), /end-of-central-directory.*ambiguous/]); + for (const [name, bytes, pattern] of cases) { + const path = join(root, `${name}.nupkg`); + await writeFile(path, bytes); + await assert.rejects( + inspectArtifactArchitecture({ path, kind: 'nupkg', platform: 'win32', arch: 'x64' }), + pattern, + ); + } + }); + + test('rejects cross-labeled package architectures at staging and finalization', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-release-wrong-arch-')); + for (const [target, targetKinds] of Object.entries(kinds)) { + const [platform, arch] = target.split('-'); + const oppositeArch = arch === 'x64' ? 'arm64' : 'x64'; + for (const kind of targetKinds.filter(candidate => candidate !== 'releases')) { + const path = join(root, `${target}-${kind}`); + await writeFile(path, `${platform}-${oppositeArch}-${kind}`); + if (kind === 'dmg') { + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + await assert.rejects( + architectureInspector({ heldArtifact: createHeldDmgArtifact(handle, path), kind, platform, arch }), + new RegExp(`${kind} packaged executable architecture mismatch`), + ); + } finally { + await handle.close(); + } + } else { + await assert.rejects( + architectureInspector({ path, kind, platform, arch }), + new RegExp(`${kind} packaged executable architecture mismatch`), + ); + } + } + } + + const makeDirectory = join(root, 'make'); + await mkdir(makeDirectory, { recursive: true }); + for (const kind of kinds['linux-x64']) { + const contents = kind === 'releases' ? '' : `linux-arm64-${kind}`; + await writeFile(join(makeDirectory, sourceName(kind)), contents); + } + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory, + outputDirectory: join(root, 'stage'), + platform: 'linux', + arch: 'x64', + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /architecture mismatch/, + ); + + const fragments = await createFragments(root); + const fragmentPath = join(fragments, 'darwin-arm64', 'release-fragment.json'); + const fragment = JSON.parse(await readFile(fragmentPath, 'utf8')); + fragment.artifacts[0].architectureEvidence.executable.architectures = ['x64']; + await writeFile(fragmentPath, `${JSON.stringify(fragment, null, 2)}\n`); + await assert.rejects( + finalizeArtifacts({ inputDirectory: fragments, outputDirectory: join(root, 'final'), version: '1.2.3', inspectArchitecture: architectureInspector }), + /architecture evidence does not match/, + ); + }); +}); diff --git a/apps/desktop/scripts/release-preflight.mjs b/apps/desktop/scripts/release-preflight.mjs new file mode 100644 index 000000000..615be2c89 --- /dev/null +++ b/apps/desktop/scripts/release-preflight.mjs @@ -0,0 +1,204 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; + +const execFile = promisify(execFileCallback); +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA_PATTERN = /^[a-f0-9]{40}$/; +const ZERO_SHA = '0'.repeat(40); +const RELEASE_ENVIRONMENT = 'desktop-release'; +const PREFLIGHT_ENVIRONMENT = 'desktop-release-preflight'; +const RELEASE_TAG_POLICY = 'desktop-v*'; +const RELEASE_TAG_RULESET_INCLUDE = `refs/tags/${RELEASE_TAG_POLICY}`; +const API_PAGE_SIZE = 100; + +const defaultGit = async args => (await execFile('git', args)).stdout.trim(); + +const apiRequest = async ({ fetchImpl, apiUrl, repository, token, path, allowNotFound = false }) => { + const response = await fetchImpl(`${apiUrl}/repos/${repository}${path}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + if (allowNotFound && response.status === 404) return undefined; + if (!response.ok) throw new Error(`GitHub API ${path} failed with HTTP ${response.status}`); + return response.json(); +}; + +const paginatedArray = async (request, path) => { + const values = []; + for (let page = 1; ; page += 1) { + const separator = path.includes('?') ? '&' : '?'; + const result = await request(`${path}${separator}per_page=${API_PAGE_SIZE}&page=${page}`); + if (!Array.isArray(result)) throw new Error(`GitHub API ${path} returned an ambiguous paginated response`); + values.push(...result); + if (result.length < API_PAGE_SIZE) return values; + } +}; + +const paginatedDeploymentPolicies = async (request, environmentName) => { + const path = `/environments/${environmentName}/deployment-branch-policies`; + const policies = []; + let totalCount; + for (let page = 1; ; page += 1) { + const result = await request(`${path}?per_page=${API_PAGE_SIZE}&page=${page}`); + if (!Number.isSafeInteger(result?.total_count) || result.total_count < 0 || !Array.isArray(result.branch_policies)) { + throw new Error(`GitHub API ${path} returned an ambiguous paginated response`); + } + if (totalCount === undefined) totalCount = result.total_count; + if (result.total_count !== totalCount || policies.length + result.branch_policies.length > totalCount) { + throw new Error(`GitHub API ${path} changed or returned inconsistent pagination`); + } + policies.push(...result.branch_policies); + if (policies.length === totalCount) return policies; + if (result.branch_policies.length !== API_PAGE_SIZE) { + throw new Error(`GitHub API ${path} omitted deployment policies during pagination`); + } + } +}; + +const assertNewTagPush = ({ event, tag }) => { + if (event.ref !== `refs/tags/${tag}` || event.created !== true || event.deleted === true || event.forced === true + || event.before !== ZERO_SHA || !SHA_PATTERN.test(event.after)) { + throw new Error('Production release must be a new, non-forced desktop tag push at the exact event SHA'); + } +}; + +const assertEnvironmentProtection = (environment, policies, environmentName) => { + if (environment?.name !== environmentName) { + throw new Error(`GitHub environment ${environmentName} does not exist`); + } + const reviewerRule = environment.protection_rules?.find(rule => rule.type === 'required_reviewers'); + if (!reviewerRule || !Array.isArray(reviewerRule.reviewers) || reviewerRule.reviewers.length === 0) { + throw new Error(`GitHub environment ${environmentName} must require reviewers`); + } + if (environment.deployment_branch_policy?.custom_branch_policies !== true + || environment.deployment_branch_policy?.protected_branches !== false) { + throw new Error(`GitHub environment ${environmentName} must use custom deployment tag restrictions`); + } + if (!Array.isArray(policies) || policies.length !== 1 + || policies[0]?.type !== 'tag' || policies[0]?.name !== RELEASE_TAG_POLICY) { + throw new Error(`GitHub environment ${environmentName} must have exactly the tag policy ${RELEASE_TAG_POLICY}`); + } +}; + +const rulesetSecurityState = ruleset => JSON.stringify({ + id: ruleset.id, + target: ruleset.target, + enforcement: ruleset.enforcement, + bypassActors: ruleset.bypass_actors, + refName: ruleset.conditions?.ref_name, + ruleTypes: Array.isArray(ruleset.rules) ? ruleset.rules.map(rule => rule?.type).sort() : ruleset.rules, +}); + +const isExactImmutableTagRuleset = ruleset => { + const refName = ruleset?.conditions?.ref_name; + const ruleTypes = Array.isArray(ruleset?.rules) ? ruleset.rules.map(rule => rule?.type) : []; + return Number.isSafeInteger(ruleset?.id) + && ruleset.target === 'tag' + && ruleset.enforcement === 'active' + && Array.isArray(ruleset.bypass_actors) + && ruleset.bypass_actors.length === 0 + && Array.isArray(refName?.include) + && refName.include.length === 1 + && refName.include[0] === RELEASE_TAG_RULESET_INCLUDE + && Array.isArray(refName.exclude) + && refName.exclude.length === 0 + && ruleTypes.includes('update') + && ruleTypes.includes('deletion'); +}; + +const readImmutableTagRuleset = async request => { + const summaries = await paginatedArray(request, '/rulesets?includes_parents=true&targets=tag'); + const ids = summaries.map(summary => summary?.id); + if (ids.some(id => !Number.isSafeInteger(id)) || new Set(ids).size !== ids.length) { + throw new Error('GitHub repository rulesets response is ambiguous'); + } + const rulesets = []; + for (const id of ids) { + rulesets.push(await request(`/rulesets/${id}?includes_parents=true`)); + } + const matching = rulesets.filter(isExactImmutableTagRuleset); + if (matching.length === 0) { + throw new Error(`Repository must have an active, bypass-free ${RELEASE_TAG_RULESET_INCLUDE} tag ruleset blocking update and deletion`); + } + return matching[0]; +}; + +export const verifyDesktopReleasePreflight = async ({ + repository, + tag, + releaseSha, + token, + event, + apiUrl = 'https://api.github.com', + fetchImpl = fetch, + git = defaultGit, +}) => { + const version = tag.startsWith('desktop-v') ? tag.slice('desktop-v'.length) : ''; + if (!VERSION_PATTERN.test(version) || !SHA_PATTERN.test(releaseSha) || !repository.includes('/') || !token) { + throw new Error('Desktop release preflight inputs are invalid'); + } + assertNewTagPush({ event, tag }); + + const request = (path, options) => apiRequest({ fetchImpl, apiUrl, repository, token, path, ...options }); + const repositoryDetails = await request(''); + if (repositoryDetails.default_branch !== 'main') throw new Error('The protected release branch must be main'); + const mainBranch = await request('/branches/main'); + if (mainBranch.protected !== true) throw new Error('Repository main branch is not protected'); + + const immutableTagRuleset = await readImmutableTagRuleset(request); + const immutableTagRulesetState = rulesetSecurityState(immutableTagRuleset); + + const encodedTag = encodeURIComponent(tag); + const currentRef = await request(`/git/ref/tags/${encodedTag}`); + if (currentRef.object?.sha !== event.after) throw new Error('Desktop release tag ref moved from the new-tag push'); + const currentCommit = await request(`/commits/${encodedTag}`); + if (currentCommit.sha !== releaseSha) throw new Error('Desktop release tag moved or does not resolve to the event SHA'); + const existingRelease = await request(`/releases/tags/${encodedTag}`, { allowNotFound: true }); + if (existingRelease) throw new Error(`GitHub release ${tag} already exists`); + + for (const environmentName of [PREFLIGHT_ENVIRONMENT, RELEASE_ENVIRONMENT]) { + const environment = await request(`/environments/${environmentName}`); + const policies = await paginatedDeploymentPolicies(request, environmentName); + assertEnvironmentProtection(environment, policies, environmentName); + } + + await git(['fetch', '--no-tags', 'origin', 'refs/heads/main:refs/remotes/origin/main']); + await git(['fetch', '--no-tags', 'origin', `refs/tags/${tag}:refs/tags/${tag}`]); + const localTagSha = await git(['rev-parse', `${tag}^{commit}`]); + if (localTagSha !== releaseSha) throw new Error('Fetched desktop release tag does not match the event SHA'); + await git(['merge-base', '--is-ancestor', releaseSha, 'refs/remotes/origin/main']); + + const stableCommit = await request(`/commits/${encodedTag}`); + if (stableCommit.sha !== releaseSha) throw new Error('Desktop release tag moved during preflight'); + const stableRef = await request(`/git/ref/tags/${encodedTag}`); + if (stableRef.object?.sha !== event.after) throw new Error('Desktop release tag ref moved during preflight'); + const racedRelease = await request(`/releases/tags/${encodedTag}`, { allowNotFound: true }); + if (racedRelease) throw new Error(`GitHub release ${tag} appeared during preflight`); + const stableRuleset = await request(`/rulesets/${immutableTagRuleset.id}?includes_parents=true`); + if (!isExactImmutableTagRuleset(stableRuleset) + || rulesetSecurityState(stableRuleset) !== immutableTagRulesetState) { + throw new Error('Desktop tag immutability ruleset changed during preflight'); + } + return { version, releaseSha, tag, tagObjectSha: event.after }; +}; + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const event = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, 'utf8')); + const result = await verifyDesktopReleasePreflight({ + repository: process.env.GITHUB_REPOSITORY, + tag: process.env.GITHUB_REF_NAME, + releaseSha: process.env.GITHUB_SHA, + token: process.env.GITHUB_TOKEN, + event, + apiUrl: process.env.GITHUB_API_URL, + }); + if (process.env.GITHUB_OUTPUT) { + const { appendFile } = await import('node:fs/promises'); + await appendFile(process.env.GITHUB_OUTPUT, `version=${result.version}\nrelease_sha=${result.releaseSha}\ntag=${result.tag}\ntag_object_sha=${result.tagObjectSha}\n`); + } +} diff --git a/apps/desktop/scripts/release-preflight.test.mjs b/apps/desktop/scripts/release-preflight.test.mjs new file mode 100644 index 000000000..a87b89e65 --- /dev/null +++ b/apps/desktop/scripts/release-preflight.test.mjs @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { verifyDesktopReleasePreflight } from './release-preflight.mjs'; + +const sha = '1'.repeat(40); +const event = { + ref: 'refs/tags/desktop-v1.2.3', + created: true, + deleted: false, + forced: false, + before: '0'.repeat(40), + after: sha, +}; + +const immutableRuleset = (overrides = {}) => ({ + id: 9, + name: 'immutable desktop release tags', + target: 'tag', + enforcement: 'active', + bypass_actors: [], + conditions: { ref_name: { include: ['refs/tags/desktop-v*'], exclude: [] } }, + rules: [{ type: 'update' }, { type: 'deletion' }], + ...overrides, +}); + +const protectedEnvironment = name => ({ + name, + protection_rules: [{ type: 'required_reviewers', reviewers: [{ type: 'Team' }] }, { type: 'branch_policy' }], + deployment_branch_policy: { protected_branches: false, custom_branch_policies: true }, +}); + +const responses = ({ protectedMain = true, environment = true, release = false, tagSha = sha } = {}) => ({ + '': { default_branch: 'main' }, + '/branches/main': { protected: protectedMain }, + '/rulesets': [{ id: 9 }], + '/rulesets/9': immutableRuleset(), + '/git/ref/tags/desktop-v1.2.3': { object: { sha } }, + '/commits/desktop-v1.2.3': { sha: tagSha }, + '/releases/tags/desktop-v1.2.3': release ? { id: 7 } : undefined, + '/environments/desktop-release-preflight': environment ? protectedEnvironment('desktop-release-preflight') : undefined, + '/environments/desktop-release-preflight/deployment-branch-policies': environment ? { + total_count: 1, + branch_policies: [{ name: 'desktop-v*', type: 'tag' }], + } : undefined, + '/environments/desktop-release': environment ? protectedEnvironment('desktop-release') : undefined, + '/environments/desktop-release/deployment-branch-policies': environment ? { + total_count: 1, + branch_policies: [{ name: 'desktop-v*', type: 'tag' }], + } : undefined, +}); + +const harness = (values, { + secondTagSha, + secondRefSha, + secondRuleset, + failures = {}, +} = {}) => { + const calls = new Map(); + const requested = []; + return { + requested, + fetchImpl: async (url, request) => { + const parsed = new URL(url); + const path = parsed.pathname.replace('/repos/integry/propr', ''); + const count = (calls.get(path) ?? 0) + 1; + calls.set(path, count); + requested.push(`${path}${parsed.search}`); + assert.equal(request.headers.Authorization, 'Bearer token'); + if (failures[path]) return { status: failures[path], ok: false, json: async () => undefined }; + let value = values[path]; + if (typeof value === 'function') value = value({ count, page: Number(parsed.searchParams.get('page') ?? 1), url: parsed }); + if (path === '/commits/desktop-v1.2.3' && count === 2 && secondTagSha) value = { sha: secondTagSha }; + if (path === '/git/ref/tags/desktop-v1.2.3' && count === 2 && secondRefSha) value = { object: { sha: secondRefSha } }; + if (path === '/rulesets/9' && count === 2 && secondRuleset !== undefined) value = secondRuleset; + return { status: value === undefined ? 404 : 200, ok: value !== undefined, json: async () => value }; + }, + git: async args => args[0] === 'rev-parse' ? sha : '', + }; +}; + +const verify = (values = responses(), options = {}) => verifyDesktopReleasePreflight({ + repository: 'integry/propr', + tag: 'desktop-v1.2.3', + releaseSha: sha, + token: 'token', + event, + ...harness(values, options), +}); + +describe('desktop release preflight', () => { + test('accepts only a new immutable tag reachable from protected main and a protected environment', async () => { + assert.deepEqual(await verify(), { version: '1.2.3', releaseSha: sha, tag: 'desktop-v1.2.3', tagObjectSha: sha }); + }); + + test('accepts an authorization-visible bypass list and fails closed for hidden or denied ruleset details', async () => { + const authorized = responses(); + authorized['/rulesets/9'] = immutableRuleset({ bypass_actors: [] }); + await verify(authorized); + + const hidden = responses(); + hidden['/rulesets/9'] = immutableRuleset({ bypass_actors: undefined }); + await assert.rejects(verify(hidden), /active, bypass-free/); + + await assert.rejects( + verify(responses(), { failures: { '/rulesets/9': 403 } }), + /rulesets\/9.*403/, + ); + }); + + test('paginates repository rulesets and reads every full rule definition', async () => { + const values = responses(); + const summaries = Array.from({ length: 101 }, (_, index) => ({ id: index + 1 })); + values['/rulesets'] = ({ page }) => page === 1 ? summaries.slice(0, 100) : summaries.slice(100); + for (let id = 1; id <= 101; id += 1) { + values[`/rulesets/${id}`] = id === 101 + ? immutableRuleset({ id }) + : immutableRuleset({ id, enforcement: 'disabled' }); + } + const configured = harness(values); + await verifyDesktopReleasePreflight({ + repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', event, ...configured, + }); + assert(configured.requested.includes('/rulesets?includes_parents=true&targets=tag&per_page=100&page=2')); + assert(configured.requested.includes('/rulesets/101?includes_parents=true')); + }); + + test('requires an exact active bypass-free update and deletion tag ruleset', async () => { + const invalidRulesets = [ + immutableRuleset({ enforcement: 'disabled' }), + immutableRuleset({ enforcement: 'evaluate' }), + immutableRuleset({ target: 'branch' }), + immutableRuleset({ bypass_actors: [{ actor_type: 'Integration', actor_id: 15368, bypass_mode: 'always' }] }), + immutableRuleset({ bypass_actors: undefined }), + immutableRuleset({ conditions: { ref_name: { include: ['refs/tags/desktop-v**'], exclude: [] } } }), + immutableRuleset({ conditions: { ref_name: { include: ['refs/tags/desktop-v*', '~ALL'], exclude: [] } } }), + immutableRuleset({ conditions: { ref_name: { include: ['refs/tags/desktop-v*'], exclude: ['refs/tags/desktop-v1.*'] } } }), + immutableRuleset({ rules: [{ type: 'update' }] }), + immutableRuleset({ rules: [{ type: 'deletion' }] }), + ]; + for (const ruleset of invalidRulesets) { + const values = responses(); + values['/rulesets/9'] = ruleset; + await assert.rejects(verify(values), /active, bypass-free.*blocking update and deletion/); + } + }); + + test('rejects ruleset mutation or deletion during preflight', async () => { + await assert.rejects( + verify(responses(), { secondRuleset: immutableRuleset({ rules: [{ type: 'update' }] }) }), + /ruleset changed during preflight/, + ); + await assert.rejects( + verify(responses(), { failures: { '/rulesets/9': 404 } }), + /rulesets\/9.*404/, + ); + const values = responses(); + values['/rulesets/9'] = ({ count }) => count === 1 ? immutableRuleset() : undefined; + await assert.rejects(verify(values), /rulesets\/9.*404/); + }); + + test('requires the complete effective environment policy set to be exactly desktop-v* tags', async () => { + const invalidPolicies = [ + [], + [{ name: '*', type: 'tag' }], + [{ name: 'desktop-v**', type: 'tag' }], + [{ name: 'desktop-v*', type: 'branch' }], + [{ name: 'desktop-v*', type: 'tag' }, { name: '*', type: 'tag' }], + [{ name: 'desktop-v*', type: 'tag' }, { name: 'main', type: 'branch' }], + ]; + for (const policies of invalidPolicies) { + const values = responses(); + values['/environments/desktop-release/deployment-branch-policies'] = { + total_count: policies.length, + branch_policies: policies, + }; + await assert.rejects(verify(values), /exactly the tag policy desktop-v\*/); + } + const fallback = responses(); + fallback['/environments/desktop-release'].deployment_branch_policy = { + protected_branches: true, + custom_branch_policies: false, + }; + await assert.rejects(verify(fallback), /custom deployment tag restrictions/); + }); + + test('requires the separately protected preflight credential environment', async () => { + const missing = responses(); + missing['/environments/desktop-release-preflight'] = undefined; + await assert.rejects(verify(missing), /environments\/desktop-release-preflight.*404/); + const permissive = responses(); + permissive['/environments/desktop-release-preflight/deployment-branch-policies'] = { + total_count: 1, + branch_policies: [{ name: '*', type: 'tag' }], + }; + await assert.rejects(verify(permissive), /desktop-release-preflight must have exactly the tag policy desktop-v\*/); + }); + + test('paginates all environment policies and rejects a permissive policy on a later page', async () => { + const values = responses(); + const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `desktop-v${index}.*`, type: 'tag' })); + values['/environments/desktop-release/deployment-branch-policies'] = ({ page }) => ({ + total_count: 101, + branch_policies: page === 1 ? firstPage : [{ name: '*', type: 'tag' }], + }); + const configured = harness(values); + await assert.rejects( + verifyDesktopReleasePreflight({ + repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', event, ...configured, + }), + /exactly the tag policy desktop-v\*/, + ); + assert(configured.requested.includes('/environments/desktop-release/deployment-branch-policies?per_page=100&page=2')); + }); + + test('rejects missing or ambiguous environment protection and explicit API denial', async () => { + await assert.rejects(verify(responses({ protectedMain: false })), /main branch is not protected/); + await assert.rejects(verify(responses({ environment: false })), /environments\/desktop-release.*404/); + await assert.rejects(verify(responses(), { failures: { '/environments/desktop-release': 403 } }), /environments\/desktop-release.*403/); + const missingReviewers = responses(); + missingReviewers['/environments/desktop-release'].protection_rules = [{ type: 'branch_policy' }]; + await assert.rejects(verify(missingReviewers), /require reviewers/); + const ambiguous = responses(); + ambiguous['/environments/desktop-release/deployment-branch-policies'] = { branch_policies: [] }; + await assert.rejects(verify(ambiguous), /ambiguous paginated response/); + }); + + test('rejects tags not created by this push, tags off main, and moved or existing releases', async () => { + await assert.rejects( + verifyDesktopReleasePreflight({ + repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', + event: { ...event, created: false, before: '2'.repeat(40) }, ...harness(responses()), + }), + /new, non-forced desktop tag push/, + ); + await assert.rejects(verify(responses({ tagSha: '2'.repeat(40) })), /tag moved/); + await assert.rejects(verify(responses({ release: true })), /already exists/); + await assert.rejects(verify(responses(), { secondTagSha: '2'.repeat(40) }), /moved during preflight/); + await assert.rejects(verify(responses(), { secondRefSha: '2'.repeat(40) }), /tag ref moved during preflight/); + const failingGit = harness(responses()); + failingGit.git = async args => { + if (args[0] === 'merge-base') throw new Error('not an ancestor'); + return args[0] === 'rev-parse' ? sha : ''; + }; + await assert.rejects( + verifyDesktopReleasePreflight({ repository: 'integry/propr', tag: 'desktop-v1.2.3', releaseSha: sha, token: 'token', event, ...failingGit }), + /not an ancestor/, + ); + }); +}); diff --git a/apps/desktop/scripts/release-publish.mjs b/apps/desktop/scripts/release-publish.mjs new file mode 100644 index 000000000..6a8b183a4 --- /dev/null +++ b/apps/desktop/scripts/release-publish.mjs @@ -0,0 +1,259 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const VERSIONED_TAG_PATTERN = /^desktop-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA_PATTERN = /^[a-f0-9]{40}$/; +const API_PAGE_SIZE = 100; +const CHECKSUM_FILE = 'SHA256SUMS'; +const REQUIRED_METADATA = ['desktop-release.json', 'desktop-release.json.sig']; + +const sha256File = async path => { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +}; + +const readFinalAssetSet = async directory => { + const entries = await readdir(directory, { withFileTypes: true }); + if (entries.some(entry => !entry.isFile())) throw new Error('Final release directory may contain only regular files'); + const names = entries.map(entry => entry.name).sort(); + if (new Set(names).size !== names.length || names.some(name => basename(name) !== name)) { + throw new Error('Final release directory contains duplicate or invalid asset names'); + } + const checksumLines = (await readFile(join(directory, CHECKSUM_FILE), 'utf8')).split(/\r?\n/).filter(Boolean); + const checksums = new Map(); + for (const line of checksumLines) { + const match = /^([a-f0-9]{64}) ([^/\\\r\n]+)$/.exec(line); + if (!match || checksums.has(match[2]) || match[2] === CHECKSUM_FILE) { + throw new Error('Finalized SHA256SUMS contains an invalid or duplicate asset'); + } + checksums.set(match[2], match[1]); + } + if (checksums.size === 0 || REQUIRED_METADATA.some(name => !checksums.has(name))) { + throw new Error('Finalized SHA256SUMS does not cover the signed release metadata'); + } + const expectedNames = [...checksums.keys(), CHECKSUM_FILE].sort(); + if (JSON.stringify(names) !== JSON.stringify(expectedNames)) { + throw new Error('Final release directory does not exactly match the finalized checksum allowlist'); + } + const assets = new Map(); + for (const name of names) { + const path = join(directory, name); + const details = await stat(path); + if (!details.isFile() || details.size <= 0) throw new Error(`Final release asset ${name} must be a nonempty regular file`); + const digest = await sha256File(path); + if (name !== CHECKSUM_FILE && digest !== checksums.get(name)) { + throw new Error(`Final release asset ${name} does not match finalized checksums`); + } + assets.set(name, { name, path, size: details.size, sha256: digest }); + } + return assets; +}; + +const githubRequest = async ({ + fetchImpl, + apiUrl, + repository, + token, + path, + method = 'GET', + json, + body, + headers = {}, + allowNotFound = false, + expectedStatus, +}) => { + const url = path.startsWith('https://') ? path : `${apiUrl}/repos/${repository}${path}`; + const response = await fetchImpl(url, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(json === undefined ? {} : { 'Content-Type': 'application/json' }), + ...headers, + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + ...(body === undefined ? {} : { body, duplex: 'half' }), + }); + if (allowNotFound && response.status === 404) return undefined; + if (!response.ok || (expectedStatus !== undefined && response.status !== expectedStatus)) { + throw new Error(`GitHub API ${method} ${path} failed with HTTP ${response.status}`); + } + return response; +}; + +const assertApprovedTag = async ({ requestJson, tag, releaseSha, tagObjectSha }) => { + const encodedTag = encodeURIComponent(tag); + const ref = await requestJson(`/git/ref/tags/${encodedTag}`); + if (ref?.object?.sha !== tagObjectSha) throw new Error('Desktop release tag object drifted from preflight approval'); + const commit = await requestJson(`/commits/${encodedTag}`); + if (commit?.sha !== releaseSha) throw new Error('Desktop release tag commit drifted from preflight approval'); +}; + +const assertDraftRelease = (release, tag) => { + if (!Number.isSafeInteger(release?.id) + || release.tag_name !== tag + || release.draft !== true + || release.prerelease !== false + || release.published_at != null + || typeof release.upload_url !== 'string') { + throw new Error('Existing GitHub release is not the exact recoverable draft for the approved tag'); + } +}; + +const listReleaseAssets = async (requestJson, releaseId) => { + const assets = []; + for (let page = 1; ; page += 1) { + const result = await requestJson(`/releases/${releaseId}/assets?per_page=${API_PAGE_SIZE}&page=${page}`); + if (!Array.isArray(result)) throw new Error('GitHub release assets response is ambiguous'); + assets.push(...result); + if (result.length < API_PAGE_SIZE) return assets; + } +}; + +const digestResponse = async (response, expectedSize, name) => { + const declaredLength = response.headers?.get?.('content-length'); + if (declaredLength !== null && declaredLength !== undefined && Number(declaredLength) !== expectedSize) { + throw new Error(`GitHub release asset ${name} has an unexpected content length`); + } + if (!response.body) throw new Error(`GitHub release asset ${name} has no downloadable body`); + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (size > expectedSize) throw new Error(`GitHub release asset ${name} exceeds its expected size`); + hash.update(chunk); + } + if (size !== expectedSize) throw new Error(`GitHub release asset ${name} has an unexpected size`); + return hash.digest('hex'); +}; + +const verifyRemoteAssets = async ({ request, requestJson, releaseId, expected, allowSubset, apiOrigin }) => { + const remote = await listReleaseAssets(requestJson, releaseId); + const seen = new Set(); + for (const asset of remote) { + if (!Number.isSafeInteger(asset?.id) || typeof asset.name !== 'string' || seen.has(asset.name)) { + throw new Error('GitHub release contains duplicate or ambiguous assets'); + } + seen.add(asset.name); + const local = expected.get(asset.name); + if (!local) throw new Error(`GitHub release contains unexpected asset ${asset.name}`); + if (asset.state !== 'uploaded' || asset.size !== local.size || typeof asset.url !== 'string') { + throw new Error(`GitHub release asset ${asset.name} metadata does not match the finalized asset`); + } + let assetUrl; + try { assetUrl = new URL(asset.url); } catch { throw new Error(`GitHub release asset ${asset.name} has an invalid API URL`); } + if (assetUrl.origin !== apiOrigin) throw new Error(`GitHub release asset ${asset.name} has an untrusted API URL`); + if (asset.digest != null && asset.digest !== `sha256:${local.sha256}`) { + throw new Error(`GitHub release asset ${asset.name} digest metadata does not match finalized checksums`); + } + const download = await request(asset.url, { headers: { Accept: 'application/octet-stream' } }); + if (await digestResponse(download, local.size, asset.name) !== local.sha256) { + throw new Error(`GitHub release asset ${asset.name} content digest does not match finalized checksums`); + } + } + if (!allowSubset && (seen.size !== expected.size || [...expected.keys()].some(name => !seen.has(name)))) { + throw new Error(`GitHub release asset set is incomplete: expected ${expected.size}, found ${seen.size}`); + } + return seen; +}; + +export const publishDesktopRelease = async ({ + repository, + tag, + releaseSha, + tagObjectSha, + directory, + token, + apiUrl = 'https://api.github.com', + fetchImpl = fetch, +}) => { + if (!repository?.includes('/') || !VERSIONED_TAG_PATTERN.test(tag) || !SHA_PATTERN.test(releaseSha) + || !SHA_PATTERN.test(tagObjectSha) || !token) { + throw new Error('Desktop release publication inputs are invalid'); + } + const finalDirectory = resolve(directory); + const expected = await readFinalAssetSet(finalDirectory); + const apiOrigin = new URL(apiUrl).origin; + const baseOptions = { fetchImpl, apiUrl, repository, token }; + const request = (path, options = {}) => githubRequest({ ...baseOptions, path, ...options }); + const requestJson = async (path, options = {}) => { + const response = await request(path, options); + return response === undefined ? undefined : response.json(); + }; + + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + const encodedTag = encodeURIComponent(tag); + let release = await requestJson(`/releases/tags/${encodedTag}`, { allowNotFound: true }); + if (release === undefined) { + release = await requestJson('/releases', { + method: 'POST', + expectedStatus: 201, + json: { + tag_name: tag, + target_commitish: releaseSha, + name: `ProPR Desktop ${tag}`, + draft: true, + prerelease: false, + generate_release_notes: true, + }, + }); + } + assertDraftRelease(release, tag); + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + + const uploaded = await verifyRemoteAssets({ + request, requestJson, releaseId: release.id, expected, allowSubset: true, apiOrigin, + }); + const uploadBase = release.upload_url.replace(/\{.*$/, ''); + let uploadOrigin; + try { uploadOrigin = new URL(uploadBase).origin; } catch { throw new Error('GitHub release returned an invalid asset upload URL'); } + const allowedUploadOrigins = new Set([apiOrigin]); + if (apiOrigin === 'https://api.github.com') allowedUploadOrigins.add('https://uploads.github.com'); + if (!allowedUploadOrigins.has(uploadOrigin)) throw new Error('GitHub release returned an untrusted asset upload URL'); + for (const asset of expected.values()) { + if (uploaded.has(asset.name)) continue; + const uploadUrl = new URL(uploadBase); + uploadUrl.searchParams.set('name', asset.name); + await request(uploadUrl.toString(), { + method: 'POST', + expectedStatus: 201, + body: createReadStream(asset.path), + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(asset.size), + }, + }); + } + + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + await verifyRemoteAssets({ + request, requestJson, releaseId: release.id, expected, allowSubset: false, apiOrigin, + }); + await assertApprovedTag({ requestJson, tag, releaseSha, tagObjectSha }); + const published = await requestJson(`/releases/${release.id}`, { + method: 'PATCH', + json: { draft: false }, + }); + if (published?.id !== release.id || published.tag_name !== tag || published.draft !== false || !published.published_at) { + throw new Error('GitHub did not confirm publication of the exact verified draft release'); + } + return published; +}; + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + await publishDesktopRelease({ + repository: process.env.GITHUB_REPOSITORY, + tag: process.env.RELEASE_TAG, + releaseSha: process.env.RELEASE_SHA, + tagObjectSha: process.env.TAG_OBJECT_SHA, + directory: process.env.RELEASE_DIRECTORY || 'desktop-release-final', + token: process.env.GITHUB_TOKEN, + apiUrl: process.env.GITHUB_API_URL, + }); +} diff --git a/apps/desktop/scripts/release-publish.test.mjs b/apps/desktop/scripts/release-publish.test.mjs new file mode 100644 index 000000000..f08fa2bd0 --- /dev/null +++ b/apps/desktop/scripts/release-publish.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { describe, test } from 'node:test'; +import { publishDesktopRelease } from './release-publish.mjs'; + +const releaseSha = '1'.repeat(40); +const tagObjectSha = '2'.repeat(40); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +const createFinalAssets = async (extraCount = 1) => { + const directory = await mkdtemp(join(tmpdir(), 'propr-publish-')); + const files = new Map([ + ['desktop-release.json', Buffer.from('{}\n')], + ['desktop-release.json.sig', Buffer.from('signed\n')], + ]); + for (let index = 0; index < extraCount; index += 1) { + files.set(`ProPR-Desktop-asset-${String(index).padStart(3, '0')}.bin`, Buffer.from(`asset-${index}\n`)); + } + const checksums = [...files] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, bytes]) => `${sha256(bytes)} ${name}`) + .join('\n'); + files.set('SHA256SUMS', Buffer.from(`${checksums}\n`)); + for (const [name, bytes] of files) await writeFile(join(directory, name), bytes); + return { directory, files }; +}; + +const response = ({ status = 200, value, bytes }) => ({ + status, + ok: status >= 200 && status < 300, + json: async () => value, + body: bytes === undefined ? undefined : Readable.from([bytes]), + headers: new Headers(bytes === undefined ? {} : { 'content-length': String(bytes.length) }), +}); + +const createGitHub = ({ seedAssets = [], failUploadAt, driftAfterTagChecks } = {}) => { + const state = { + release: undefined, + assets: seedAssets.map(asset => ({ ...asset })), + calls: [], + patchCalls: 0, + uploadCalls: 0, + failUploadAt, + tagChecks: 0, + }; + const draft = () => ({ + id: 7, + tag_name: 'desktop-v1.2.3', + draft: true, + prerelease: false, + published_at: null, + upload_url: 'https://uploads.github.com/releases/7/assets{?name,label}', + }); + if (seedAssets.length) state.release = draft(); + state.fetchImpl = async (url, options = {}) => { + const parsed = new URL(url); + const path = parsed.pathname.replace('/repos/integry/propr', ''); + const method = options.method ?? 'GET'; + state.calls.push(`${method} ${path}${parsed.search}`); + if (path === '/git/ref/tags/desktop-v1.2.3') { + state.tagChecks += 1; + const drifted = driftAfterTagChecks && state.tagChecks >= driftAfterTagChecks; + return response({ value: { object: { sha: drifted ? '3'.repeat(40) : tagObjectSha } } }); + } + if (path === '/commits/desktop-v1.2.3') return response({ value: { sha: releaseSha } }); + if (path === '/releases/tags/desktop-v1.2.3') { + return state.release ? response({ value: state.release }) : response({ status: 404 }); + } + if (path === '/releases' && method === 'POST') { + const input = JSON.parse(options.body); + assert.equal(input.draft, true); + assert.equal(input.tag_name, 'desktop-v1.2.3'); + assert.equal(input.target_commitish, releaseSha); + state.release = draft(); + return response({ status: 201, value: state.release }); + } + if (path === '/releases/7/assets' && method === 'GET') { + const page = Number(parsed.searchParams.get('page')); + return response({ value: state.assets.slice((page - 1) * 100, page * 100) }); + } + if (parsed.host === 'uploads.github.com' && method === 'POST') { + state.uploadCalls += 1; + if (state.failUploadAt === state.uploadCalls) return response({ status: 500 }); + const chunks = []; + for await (const chunk of options.body) chunks.push(chunk); + const bytes = Buffer.concat(chunks); + const name = parsed.searchParams.get('name'); + const asset = { + id: state.assets.length + 1, + name, + state: 'uploaded', + size: bytes.length, + digest: `sha256:${sha256(bytes)}`, + url: `https://api.github.com/assets/${state.assets.length + 1}`, + bytes, + }; + state.assets.push(asset); + return response({ status: 201, value: asset }); + } + if (parsed.host === 'api.github.com' && path.startsWith('/assets/')) { + const asset = state.assets.find(candidate => candidate.url === url); + return asset ? response({ bytes: asset.bytes }) : response({ status: 404 }); + } + if (path === '/releases/7' && method === 'PATCH') { + state.patchCalls += 1; + state.release = { ...state.release, draft: false, published_at: '2026-08-29T00:00:00Z' }; + return response({ value: state.release }); + } + throw new Error(`Unexpected request: ${method} ${url}`); + }; + return state; +}; + +const publish = ({ directory, fetchImpl }) => publishDesktopRelease({ + repository: 'integry/propr', + tag: 'desktop-v1.2.3', + releaseSha, + tagObjectSha, + directory, + token: 'token', + apiUrl: 'https://api.github.com', + fetchImpl, +}); + +describe('atomic desktop release publication', () => { + test('creates a draft, paginates and verifies the exact final assets, then publishes', async () => { + const { directory } = await createFinalAssets(101); + const github = createGitHub(); + const result = await publish({ directory, fetchImpl: github.fetchImpl }); + assert.equal(result.draft, false); + assert.equal(github.patchCalls, 1); + assert.equal(github.assets.length, 104); + assert(github.calls.includes('GET /releases/7/assets?per_page=100&page=2')); + assert(github.calls.lastIndexOf('GET /git/ref/tags/desktop-v1.2.3') < github.calls.indexOf('PATCH /releases/7')); + }); + + test('leaves a partial upload as a recoverable draft and resumes only matching assets', async () => { + const { directory, files } = await createFinalAssets(2); + const github = createGitHub({ failUploadAt: 2 }); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /failed with HTTP 500/); + assert.equal(github.release.draft, true); + assert.equal(github.patchCalls, 0); + assert.equal(github.assets.length, 1); + + github.failUploadAt = undefined; + await publish({ directory, fetchImpl: github.fetchImpl }); + assert.equal(github.release.draft, false); + assert.equal(github.assets.length, files.size); + assert.equal(new Set(github.assets.map(asset => asset.name)).size, files.size); + }); + + test('rejects unexpected, duplicate, size, and content-digest asset mismatches without publishing', async () => { + const { directory, files } = await createFinalAssets(); + const [name, bytes] = [...files].find(([candidate]) => candidate !== 'SHA256SUMS'); + const matching = { + id: 1, + name, + state: 'uploaded', + size: bytes.length, + digest: `sha256:${sha256(bytes)}`, + url: 'https://api.github.com/assets/1', + bytes, + }; + const cases = [ + [{ ...matching, name: 'unexpected.bin' }], + [matching, { ...matching, id: 2, url: 'https://api.github.com/assets/2' }], + [{ ...matching, size: bytes.length + 1 }], + [{ ...matching, bytes: Buffer.from('x'.repeat(bytes.length)), digest: `sha256:${sha256(bytes)}` }], + ]; + for (const assets of cases) { + const github = createGitHub({ seedAssets: assets }); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /unexpected|duplicate|metadata|content digest/); + assert.equal(github.patchCalls, 0); + assert.equal(github.release.draft, true); + } + }); + + test('rejects tag drift before publishing the verified draft', async () => { + const { directory } = await createFinalAssets(); + const github = createGitHub({ driftAfterTagChecks: 4 }); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /tag object drifted/); + assert.equal(github.patchCalls, 0); + assert.equal(github.release.draft, true); + }); + + test('rejects local files outside or missing from finalized checksums', async () => { + const { directory } = await createFinalAssets(); + await writeFile(join(directory, 'unexpected.bin'), 'unexpected'); + const github = createGitHub(); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /checksum allowlist/); + assert.equal(github.calls.length, 0); + }); +}); diff --git a/apps/desktop/scripts/smoke-packaged.mjs b/apps/desktop/scripts/smoke-packaged.mjs index 421becd6e..c90c9303d 100644 --- a/apps/desktop/scripts/smoke-packaged.mjs +++ b/apps/desktop/scripts/smoke-packaged.mjs @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; -import { access, mkdtemp, rm } from 'node:fs/promises'; +import { access, mkdtemp, readdir, rm } from 'node:fs/promises'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; @@ -11,6 +11,7 @@ import { FuseVersion, getCurrentFuseWire, } from '@electron/fuses'; +import { inspectPackagedWindowsAuthority } from './inspect-packaged-windows-authority.mjs'; const READY_EVENT = 'desktop.renderer.ready'; const PRELOAD_BRIDGE_PROOF = '"preloadBridgeExposed":true'; @@ -22,7 +23,33 @@ const MAIN_PROCESS_ERROR_MARKERS = [ 'Uncaught Exception:', ]; const TIMEOUT_MS = 30_000; -const binaryPath = resolve('out', `propr-desktop-linux-${process.arch}`, 'propr-desktop'); +const binaryPath = process.platform === 'darwin' + ? resolve('out', `propr-desktop-darwin-${process.arch}`, 'propr-desktop.app', 'Contents', 'MacOS', 'propr-desktop') + : resolve( + 'out', + `propr-desktop-${process.platform}-${process.arch}`, + `propr-desktop${process.platform === 'win32' ? '.exe' : ''}`, + ); +const inspectOnly = process.argv.includes('--inspect-only'); + +if (process.platform === 'win32') { + const helperDirectory = resolve('out', `propr-desktop-win32-${process.arch}`, 'resources', 'windows-authority'); + const entries = (await readdir(helperDirectory)).sort(); + if (entries.length !== 4 || entries[0] !== 'propr-windows-authority.exe' + || entries[1] !== 'propr-windows-authority.manifest.json' + || entries[2] !== 'propr-windows-bootstrap.node' + || entries[3] !== 'propr-windows-launcher.node') { + throw new Error('Packaged Windows authority helper layout is missing or ambiguous'); + } + const manifest = await inspectPackagedWindowsAuthority( + resolve(helperDirectory, entries[0]), + resolve(helperDirectory, entries[1]), + ); + const expectedTrust = process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1' + ? 'production-signed' + : 'unsigned-validation'; + if (manifest.trust !== expectedTrust) throw new Error('Packaged Windows authority helper trust mode is incorrect'); +} const parseLayout = smokeOutput => { for (const line of smokeOutput.split(/\r?\n/)) { @@ -80,10 +107,6 @@ const assertPackagedLayout = layout => { assertGap(layout.submit, layout.footer, 20, 'between submit button and runtime footer'); }; -if (process.platform !== 'linux') { - throw new Error('The packaged-binary smoke test currently targets the Linux artifact'); -} - await access(binaryPath); const expectedFuses = new Map([ @@ -111,6 +134,11 @@ for (const [fuse, expectedState] of expectedFuses) { } } +if (inspectOnly) { + console.log(`Packaged ${process.platform}-${process.arch} desktop artifact passed executable and fuse inspection.`); + process.exit(0); +} + const userDataPath = await mkdtemp(resolve(tmpdir(), 'propr-desktop-smoke-')); const launchArguments = ['--disable-gpu', `--user-data-dir=${userDataPath}`]; if (launchArguments.some(argument => argument === '--no-sandbox' || argument === '--disable-sandbox')) { @@ -196,7 +224,7 @@ try { } assertPackagedLayout(parseLayout(output)); - console.log('Packaged Linux desktop reached renderer-ready with compiled layout, sandboxing, and profile API proof.'); + console.log(`Packaged ${process.platform}-${process.arch} desktop reached renderer-ready with compiled layout, sandboxing, and profile API proof.`); } finally { profileApiServer.closeAllConnections(); await new Promise(resolveClose => profileApiServer.close(resolveClose)); diff --git a/apps/desktop/scripts/test-installed-windows-authority.ps1 b/apps/desktop/scripts/test-installed-windows-authority.ps1 new file mode 100644 index 000000000..6cda68807 --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-authority.ps1 @@ -0,0 +1,91 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture +) +$ErrorActionPreference = 'Stop' +$installerPath = (Resolve-Path -LiteralPath $Installer).Path +$installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' +$application = Join-Path $installRoot 'propr-desktop.exe' +$authority = Join-Path $installRoot 'resources\windows-authority' +$helper = Join-Path $authority 'propr-windows-authority.exe' +$testUser = "propr-ci-$([Guid]::NewGuid().ToString('N').Substring(0,8))" +$passwordText = "P!$([Guid]::NewGuid().ToString('N'))a7" +$password = ConvertTo-SecureString $passwordText -AsPlainText -Force +$credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$testUser", $password) + +try { + $install = Start-Process msiexec.exe -ArgumentList @('/i', "`"$installerPath`"", '/qn', '/norestart') -Wait -PassThru + if ($install.ExitCode -notin @(0,3010)) { throw "machine installer exited $($install.ExitCode)" } + if (!(Test-Path -LiteralPath $application -PathType Leaf) -or !(Test-Path -LiteralPath $helper -PathType Leaf)) { + throw 'machine installer did not install the canonical application authority layout' + } + $image = New-Object byte[] 4096 + $imageStream = [IO.File]::OpenRead($application) + try { $imageLength = $imageStream.Read($image,0,$image.Length) } finally { $imageStream.Dispose() } + if ($imageLength -lt 512 -or [BitConverter]::ToUInt16($image,0) -ne 0x5a4d) { throw 'installed application is not PE' } + $pe = [BitConverter]::ToUInt32($image,0x3c) + $expectedMachine = if ($Architecture -eq 'arm64') { 0xaa64 } else { 0x8664 } + if ($pe + 6 -gt $imageLength -or [Text.Encoding]::ASCII.GetString($image,[int]$pe,4) -cne "PE`0`0" -or + [BitConverter]::ToUInt16($image,[int]$pe+4) -ne $expectedMachine) { + throw 'installed application architecture does not match the matrix target' + } + foreach ($protectedPath in @($installRoot, $application, $authority, $helper)) { + $acl = Get-Acl -LiteralPath $protectedPath + $owner = (New-Object Security.Principal.NTAccount($acl.Owner)).Translate([Security.Principal.SecurityIdentifier]).Value + if ($owner -cne 'S-1-5-18' -or !$acl.AreAccessRulesProtected) { + throw "$protectedPath is not SYSTEM-owned with a protected DACL" + } + foreach ($rule in $acl.Access) { + if ($rule.IsInherited) { throw "$protectedPath retains an inherited effective ACE" } + $dangerous = [Security.AccessControl.FileSystemRights]::WriteData -bor + [Security.AccessControl.FileSystemRights]::AppendData -bor + [Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor + [Security.AccessControl.FileSystemRights]::WriteAttributes -bor + [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [Security.AccessControl.FileSystemRights]::Delete -bor + [Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [Security.AccessControl.FileSystemRights]::TakeOwnership + if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $dangerous) -ne 0) { + $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + if ($sid -notin @('S-1-5-18', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464')) { + throw "$protectedPath grants mutation to $sid" + } + } + } + } + + New-LocalUser -Name $testUser -Password $password -AccountNeverExpires -PasswordNeverExpires | Out-Null + $process = Start-Process -FilePath $application -ArgumentList '--propr-authority-smoke' -Credential $credential -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "standard-user installed authority handshake exited $($process.ExitCode)" } + + $helper64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($helper)) + $authority64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($authority)) + $application64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($application)) + $attack = @" +`$helper=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$helper64')) +`$authority=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$authority64')) +`$application=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('$application64')) +`$failed=`$false +function Denied([scriptblock]`$operation) { + try { & `$operation; `$script:failed=`$true } + catch [UnauthorizedAccessException] { } + catch [IO.IOException] { if (`$_.Exception.HResult -notin @(-2147024891,-2147024864,-2147024713)) { throw } } +} +Denied { [IO.File]::OpenWrite(`$helper).Dispose() } +Denied { [IO.File]::Delete(`$helper) } +Denied { [IO.File]::Move(`$helper,"`$helper.replaced") } +Denied { [IO.File]::WriteAllBytes((Join-Path `$authority 'replacement.node'),[byte[]](1,2,3)) } +Denied { [IO.File]::OpenWrite(`$application).Dispose() } +Denied { [IO.File]::Delete(`$application) } +Denied { [IO.File]::Move(`$application,"`$application.replaced") } +if (`$failed) { exit 1 } else { exit 0 } +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($attack)) + $attackProcess = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') ` + -ArgumentList @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$encoded) ` + -Credential $credential -Wait -PassThru + if ($attackProcess.ExitCode -ne 0) { throw 'standard user could mutate or replace the installed authority' } +} finally { + if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { Remove-LocalUser -Name $testUser } + Start-Process msiexec.exe -ArgumentList @('/x', "`"$installerPath`"", '/qn', '/norestart') -Wait | Out-Null +} diff --git a/apps/desktop/scripts/verify-darwin-image.mjs b/apps/desktop/scripts/verify-darwin-image.mjs new file mode 100644 index 000000000..eb9edf130 --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-image.mjs @@ -0,0 +1,162 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { chmod, lstat, mkdtemp, open, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCallback); +const HDIUTIL = '/usr/bin/hdiutil'; +const MAX_DMG_BYTES = 8 * 1024 * 1024 * 1024; +const TRANSIENT_VERIFY_FAILURE = /^hdiutil: verify failed - (?:Resource temporarily unavailable|Resource busy)\s*$/; + +const hashHeld = async (handle, size) => { + const hash = createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < Number(size)) { + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, Number(size) - position), position); + if (bytesRead <= 0) throw new Error('DMG bytes changed while held'); + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + return hash.digest('hex'); +}; + +const acquireCanonicalImage = async path => { + const canonical = await realpath(path); + if (canonical !== resolve(path)) throw new Error('DMG verification requires a canonical image pathname'); + const pathStats = await lstat(path, { bigint: true }); + if (!pathStats.isFile() || pathStats.isSymbolicLink() || pathStats.nlink !== 1n + || pathStats.size <= 0n || pathStats.size > BigInt(MAX_DMG_BYTES)) { + throw new Error('DMG verification requires one nonempty regular image'); + } + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile() || stats.dev !== pathStats.dev || stats.ino !== pathStats.ino + || stats.size !== pathStats.size || stats.nlink !== 1n) { + throw new Error('DMG identity changed before verification'); + } + return { path: canonical, handle, stats, sha256: await hashHeld(handle, stats.size) }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +}; + +const sameStats = (left, right) => left.dev === right.dev && left.ino === right.ino + && left.size === right.size && left.nlink === right.nlink; + +const reverifyHeld = async (image, label) => { + const stats = await image.handle.stat({ bigint: true }); + if (!sameStats(stats, image.stats) || await hashHeld(image.handle, stats.size) !== image.sha256) { + throw new Error(`DMG identity or checksum changed during ${label}`); + } + const pathStats = await lstat(image.path, { bigint: true }).catch(() => undefined); + if (!pathStats || !sameStats(pathStats, stats) || pathStats.isSymbolicLink()) { + throw new Error(`DMG pathname changed during ${label}`); + } +}; + +const createProtectedSnapshot = async source => { + const createdRoot = await mkdtemp(join(tmpdir(), 'propr-dmg-verify-')); + const root = await realpath(createdRoot); + const path = join(root, 'image.dmg'); + let writer; + let handle; + try { + writer = await open(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL + | fsConstants.O_NOFOLLOW, 0o600); + const buffer = Buffer.alloc(1024 * 1024); + let position = 0; + while (position < Number(source.stats.size)) { + const { bytesRead } = await source.handle.read( + buffer, 0, Math.min(buffer.length, Number(source.stats.size) - position), position, + ); + if (bytesRead <= 0) throw new Error('DMG bytes changed while creating the verification lease'); + let written = 0; + while (written < bytesRead) { + const result = await writer.write(buffer, written, bytesRead - written, position + written); + if (result.bytesWritten <= 0) throw new Error('DMG verification snapshot write failed'); + written += result.bytesWritten; + } + position += bytesRead; + } + await writer.sync(); + await writer.close(); + writer = undefined; + await chmod(path, 0o400); + handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const stats = await handle.stat({ bigint: true }); + const sha256 = await hashHeld(handle, stats.size); + if (!stats.isFile() || stats.nlink !== 1n || stats.size !== source.stats.size || sha256 !== source.sha256) { + throw new Error('DMG verification snapshot does not match the held source'); + } + // Deny creation, rename, and deletion for the entire hdiutil interval. + // The randomized parent is searchable but neither enumerable nor writable. + await chmod(root, 0o500); + return { root, path, handle, stats, sha256 }; + } catch (error) { + await writer?.close().catch(() => undefined); + await handle?.close().catch(() => undefined); + await chmod(root, 0o700).catch(() => undefined); + await rm(root, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +}; + +const releaseSnapshot = async snapshot => { + await snapshot.handle.close().catch(() => undefined); + await chmod(snapshot.root, 0o700).catch(() => undefined); + await chmod(snapshot.path, 0o600).catch(() => undefined); + await rm(snapshot.root, { recursive: true, force: true }); +}; + +export const verifyDarwinImage = async (path, { + run = (file, arguments_) => execFile(file, arguments_, { timeout: 120_000, maxBuffer: 64 * 1024 }), + wait = milliseconds => new Promise(resolvePromise => setTimeout(resolvePromise, milliseconds)), + nativePlatform = process.platform, +} = {}) => { + if (nativePlatform !== 'darwin') throw new Error('DMG verification requires native macOS'); + const source = await acquireCanonicalImage(path); + let snapshot; + try { + snapshot = await createProtectedSnapshot(source); + await reverifyHeld(source, 'private snapshot creation'); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await run(HDIUTIL, ['verify', snapshot.path]); + } catch (error) { + const stderr = typeof error === 'object' && error !== null && typeof error.stderr === 'string' ? error.stderr : ''; + if (!TRANSIENT_VERIFY_FAILURE.test(stderr) || attempt === 2) { + throw new Error(TRANSIENT_VERIFY_FAILURE.test(stderr) + ? 'Native DMG verification remained busy after bounded retries' + : 'Native DMG verification rejected the image'); + } + await reverifyHeld(snapshot, 'verification retry'); + await reverifyHeld(source, 'verification retry'); + await wait(250 * (attempt + 1)); + continue; + } + await reverifyHeld(snapshot, 'verification'); + await reverifyHeld(source, 'verification'); + return { size: Number(source.stats.size), sha256: source.sha256, attempts: attempt + 1 }; + } + throw new Error('Native DMG verification exhausted its bounded retry policy'); + } finally { + try { + if (snapshot) await releaseSnapshot(snapshot); + } finally { + await source.handle.close().catch(() => undefined); + } + } +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.length !== 3) throw new Error('Expected exactly one DMG pathname'); + await verifyDarwinImage(process.argv[2]); + process.stdout.write('Native DMG verification passed\n'); +} diff --git a/apps/desktop/scripts/verify-darwin-image.test.mjs b/apps/desktop/scripts/verify-darwin-image.test.mjs new file mode 100644 index 000000000..e49458ba7 --- /dev/null +++ b/apps/desktop/scripts/verify-darwin-image.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, rename, rm, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { verifyDarwinImage } from './verify-darwin-image.mjs'; + +test('Darwin image verification retries only bounded documented resource states', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-verify-')); + const image = join(await realpath(root), 'fixture.dmg'); + try { + await writeFile(image, 'canonical-darwin-fixture'); + let calls = 0; + const result = await verifyDarwinImage(image, { + nativePlatform: 'darwin', + wait: async () => undefined, + run: async () => { + calls += 1; + if (calls < 3) throw Object.assign(new Error('busy'), { + stderr: calls === 1 + ? 'hdiutil: verify failed - Resource temporarily unavailable\n' + : 'hdiutil: verify failed - Resource busy\n', + }); + }, + }); + assert.equal(result.attempts, 3); + assert.equal(calls, 3); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('Darwin image verification does not retry malformed/truncated images or accept mutation', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-malformed-')); + const image = join(await realpath(root), 'fixture.dmg'); + try { + await writeFile(image, 'canonical-darwin-fixture'); + let malformedCalls = 0; + await assert.rejects(verifyDarwinImage(image, { + nativePlatform: 'darwin', + wait: async () => undefined, + run: async () => { + malformedCalls += 1; + throw Object.assign(new Error('malformed'), { stderr: 'hdiutil: verify failed - image not recognized\n' }); + }, + }), /rejected the image/); + assert.equal(malformedCalls, 1); + + await assert.rejects(verifyDarwinImage(image, { + nativePlatform: 'darwin', + run: async () => { await truncate(image, 3); }, + }), /identity or checksum changed/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('Darwin image verification holds a fixed hdiutil image behind a real mutation and replacement barrier', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-dmg-lease-')); + const image = join(await realpath(root), 'fixture.dmg'); + try { + await writeFile(image, 'canonical-darwin-fixture'); + let verifiedPath; + const result = await verifyDarwinImage(image, { + nativePlatform: 'darwin', + run: async (file, arguments_) => { + assert.equal(file, '/usr/bin/hdiutil'); + assert.equal(arguments_[0], 'verify'); + verifiedPath = arguments_[1]; + await assert.rejects(writeFile(verifiedPath, 'mutated'), error => ['EACCES', 'EPERM'].includes(error.code)); + await assert.rejects( + rename(verifiedPath, `${verifiedPath}.displaced`), + error => ['EACCES', 'EPERM'].includes(error.code), + ); + }, + }); + assert.equal(result.sha256.length, 64); + assert.notEqual(verifiedPath, image); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/apps/desktop/scripts/windows-authority-build.test.mjs b/apps/desktop/scripts/windows-authority-build.test.mjs new file mode 100644 index 000000000..3180c0812 --- /dev/null +++ b/apps/desktop/scripts/windows-authority-build.test.mjs @@ -0,0 +1,363 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + inspectAnyCpuPe, + preserveWindowsAuthorityCompilerFailure, + buildWindowsAuthorityHelper, + decodeWindowsSystemDirectoryRecord, + resolveWindowsCompilerLayout, + validateWindowsAuthoritySource, + WINDOWS_AUTHORITY_COMPILER_SUBSTAGES, + WINDOWS_AUTHORITY_EXECUTABLE, + WINDOWS_AUTHORITY_MANIFEST, + WINDOWS_AUTHORITY_SOURCE, +} from './build-windows-authority-helper.mjs'; +import { prepareWindowsAuthorityBuildDirectory } from './build-windows-native-launcher.mjs'; +import { + inspectPackagedWindowsAuthority, + refreshPackagedWindowsAuthorityManifest, +} from './inspect-packaged-windows-authority.mjs'; + +const windowsNativeBuildOnly = { + skip: process.platform !== 'win32' || process.env.PROPR_WINDOWS_AUTHORITY_NATIVE_BUILD_TESTS !== '1', +}; +const compilerInputEvidence = (name, sha256) => ({ + name, + size: 1, + sha256, + signerCertificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + signerSpkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + signerRootSpkiSha256: '3'.repeat(64), + catalogName: 'Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat', + catalogSha256: 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef', + catalogVolumeSerial: '5'.repeat(16), + catalogFileId128: '6'.repeat(32), +}); + +const managedPe = () => { + const bytes = Buffer.alloc(1024); + bytes.writeUInt16LE(0x5a4d, 0); + bytes.writeUInt32LE(0x80, 0x3c); + bytes.write('PE\0\0', 0x80, 'ascii'); + bytes.writeUInt16LE(0x14c, 0x84); + bytes.writeUInt16LE(1, 0x86); + bytes.writeUInt16LE(224, 0x94); + bytes.writeUInt16LE(0x10b, 0x98); + bytes.writeUInt32LE(0x2000, 0x98 + 96 + (14 * 8)); + bytes.writeUInt32LE(72, 0x98 + 96 + (14 * 8) + 4); + bytes.writeUInt32LE(0x200, 0x178 + 8); + bytes.writeUInt32LE(0x2000, 0x178 + 12); + bytes.writeUInt32LE(0x200, 0x178 + 16); + bytes.writeUInt32LE(0x200, 0x178 + 20); + bytes.writeUInt32LE(0x1, 0x210); + return bytes; +}; + +const systemDirectoryRecord = path => { + const output = Buffer.alloc(2 + (520 * 2)); + output.writeUInt16LE(path.length, 0); + output.write(path, 2, 'utf16le'); + return output; +}; + +test('bounded Windows system-directory channel rejects NT aliases, malformed records, and trailing data', () => { + assert.equal(decodeWindowsSystemDirectoryRecord(systemDirectoryRecord('C:\\Windows')), 'C:\\Windows'); + assert.throws(() => decodeWindowsSystemDirectoryRecord(systemDirectoryRecord('\\\\?\\GLOBALROOT\\SystemRoot')), /BUILD_COMPILER/); + assert.throws(() => decodeWindowsSystemDirectoryRecord(Buffer.alloc(8)), /BUILD_COMPILER/); + const trailing = systemDirectoryRecord('C:\\Windows'); + trailing[trailing.length - 1] = 1; + assert.throws(() => decodeWindowsSystemDirectoryRecord(trailing), /BUILD_COMPILER/); +}); + +test('compiler failures expose only fixed non-secret authenticate-to-spawn substages', () => { + assert.deepEqual(WINDOWS_AUTHORITY_COMPILER_SUBSTAGES, [ + 'DIRECTORY_PROBE', 'CATALOG_ENUMERATION', 'MEMBER_TAG', 'CATALOG_HASH', 'WINTRUST_POLICY', + 'REVOCATION', 'CATALOG_LEASE', 'SIGNER_PARSE', 'EXACT_PUBLISHER', 'ROOT_PIN', 'CERTIFICATE_PIN', + 'SPKI_PIN', 'COMPILER_OPEN', 'REFERENCE_OPEN', 'SIGNER_CATALOG', 'LEASE', 'SOURCE_COPY', 'SPAWN', + 'IMAGE', 'EXIT', 'OUTPUT_VALIDATION', + ]); + assert.ok(WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.every(stage => /^[A-Z_]{4,24}$/.test(stage))); +}); + +test('compiler layout preserves recognized probe substages and redacts unknown failures', async () => { + for (const substage of WINDOWS_AUTHORITY_COMPILER_SUBSTAGES) { + const recognized = Object.assign(new Error('host detail must not escape'), { + stage: 'BUILD_COMPILER', + substage, + }); + await assert.rejects( + resolveWindowsCompilerLayout({}, async () => { throw recognized; }), + error => error instanceof Error + && error.message === `Windows authority helper build failed [win-authority:BUILD_COMPILER:${substage}]` + && !error.message.includes('host detail'), + ); + } + await assert.rejects( + resolveWindowsCompilerLayout({}, async () => { throw new Error('C:\\secret\\host-path'); }), + error => error instanceof Error + && error.message === 'Windows authority helper build failed [win-authority:BUILD_COMPILER:DIRECTORY_PROBE]' + && !error.message.includes('secret'), + ); +}); + +test('every native build boundary preserves only the fixed secret-free compiler stage vocabulary', () => { + for (const substage of WINDOWS_AUTHORITY_COMPILER_SUBSTAGES) { + const exact = Object.assign(new Error('C:\\host-detail-must-not-be-rendered'), { + stage: 'BUILD_COMPILER', substage, code: substage, + }); + assert.throws( + () => preserveWindowsAuthorityCompilerFailure(exact), + error => error instanceof Error + && error.message === `Windows authority helper build failed [win-authority:BUILD_COMPILER:${substage}]` + && !error.message.includes('host-detail'), + ); + assert.throws( + () => preserveWindowsAuthorityCompilerFailure(Object.assign(new Error('raw native detail'), { code: substage })), + error => error instanceof Error + && error.message === `Windows authority helper build failed [win-authority:BUILD_COMPILER:${substage}]` + && !error.message.includes('raw native detail'), + ); + } + assert.throws( + () => preserveWindowsAuthorityCompilerFailure(new Error('C:\\secret\\compiler.log')), + error => error instanceof Error + && error.message === 'Windows authority helper build failed [win-authority:BUILD_COMPILER:DIRECTORY_PROBE]' + && !error.message.includes('secret'), + ); +}); + +test('system catalog policy is standalone, cache-only, held, and independently diagnosable', async () => { + const source = await readFile(new URL('../src/native/windows-launcher/propr_windows_launcher.cc', import.meta.url), 'utf8'); + assert.match(source, /SignerContent::StandaloneCatalog/); + assert.match(source, /WTD_CACHE_ONLY_URL_RETRIEVAL/); + assert.match(source, /CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY/); + assert.match(source, /CERT_TRUST_IS_REVOKED/); + assert.match(source, /SameHeldCatalog\(catalogs\[index\], catalog_identities\[index\], catalog_hashes\[index\]\)/); + assert.match(source, /kMicrosoftCatalogPolicy/); + assert.match(source, /ApprovedMicrosoftCatalog/); + assert.doesNotMatch(source, /compiler-(?:wrong-signer|same-root-wrong-certificate|same-root-wrong-signer|subject-spoof|wrong-spki|manifest-replacement)/); + assert.doesNotMatch(source, /\(void\)presented/); + assert.doesNotMatch(source, /certificate->size\(\)\s*!=\s*64|spki->size\(\)\s*!=\s*64/); + for (const digest of [ + '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef', + 'fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85', + ]) assert.match(source, new RegExp(digest)); + for (const code of WINDOWS_AUTHORITY_COMPILER_SUBSTAGES.slice(1, 12)) { + assert.match(source, new RegExp(`"${code}"`)); + } +}); + +test('compiler layout treats SystemRoot and windir as disagreement checks and rejects reparse references', async () => { + const canonicalTempRoot = await realpath(tmpdir()); + const root = await realpath(await mkdtemp(join(canonicalTempRoot, 'propr-system-directory-'))); + try { + const framework = join(root, 'Microsoft.NET', 'Framework64', 'v4.0.30319'); + await mkdir(framework, { recursive: true }); + for (const name of ['csc.exe', 'System.dll', 'System.Web.Extensions.dll']) await writeFile(join(framework, name), name); + await chmod(join(framework, 'csc.exe'), 0o700); + const exact = await resolveWindowsCompilerLayout({ SystemRoot: root, windir: root }, async () => root); + assert.equal(exact.systemRoot, await realpath(root)); + await assert.rejects(resolveWindowsCompilerLayout({ SystemRoot: root, windir: join(root, 'fake') }, async () => root), + /BUILD_COMPILER/); + await rm(join(framework, 'System.dll')); + await symlink(join(framework, 'System.Web.Extensions.dll'), join(framework, 'System.dll')); + await assert.rejects(resolveWindowsCompilerLayout({}, async () => root), /BUILD_COMPILER/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('committed Windows broker source is nonempty strict UTF-8 with a real executable entrypoint', async () => { + const source = await readFile(WINDOWS_AUTHORITY_SOURCE); + assert.match(validateWindowsAuthoritySource(source), /^[a-f0-9]{64}$/); + assert.throws(() => validateWindowsAuthoritySource(Buffer.alloc(0)), /BUILD_SOURCE/); + assert.throws(() => validateWindowsAuthoritySource(Buffer.from([0xc3, 0x28])), /BUILD_SOURCE/); + assert.throws(() => validateWindowsAuthoritySource(Buffer.from('public class SourceOnly {}')), /BUILD_SOURCE/); +}); + +test('native compiler leases defeat compiler, reference, and exact-source substitution barriers', windowsNativeBuildOnly, async () => { + for (const fault of [ + 'compiler-swap-after-open', 'reference-swap-after-open', 'compiler-swap-before-create', + 'reference-swap-before-create', 'compiler-swap-after-process', 'source-swap-after-copy', 'source-rename', + 'source-hardlink', 'source-reparse', 'source-truncate', 'source-replace', + ]) { + const result = await buildWindowsAuthorityHelper({ + ...process.env, + PROPR_WINDOWS_AUTHORITY_TEST_COMPILER_FAULT: fault, + }); + assert.equal(result.skipped, false); + assert.match(result.sourceSha256, /^[a-f0-9]{64}$/); + assert.match(result.compiler.fileId128, /^[a-f0-9]{32}$/); + assert.match(result.compiler.signerCertificateSha256, /^[a-f0-9]{64}$/); + assert.match(result.compiler.signerSpkiSha256, /^[a-f0-9]{64}$/); + for (const input of result.compiler.inputs) { + assert.match(input.catalogSha256, /^[a-f0-9]{64}$/); + assert.match(input.catalogVolumeSerial, /^[a-f0-9]{16}$/); + assert.match(input.catalogFileId128, /^[a-f0-9]{32}$/); + } + } +}); + +test('native compiler signer, image, job, exit, and output failures stay bounded and clean', windowsNativeBuildOnly, async () => { + const cases = [ + ['compiler-wrong-catalog', 'CATALOG_HASH'], + ['compiler-swapped-catalog', 'CATALOG_LEASE'], + ['compiler-job', 'IMAGE'], + ['compiler-image', 'IMAGE'], + ['compiler-exit', 'EXIT'], + ['compiler-output', 'OUTPUT_VALIDATION'], + ]; + for (const [fault, substage] of cases) { + await prepareWindowsAuthorityBuildDirectory(); + await Promise.all([ + rm(WINDOWS_AUTHORITY_EXECUTABLE, { force: true }), + rm(WINDOWS_AUTHORITY_MANIFEST, { force: true }), + ]); + await assert.rejects( + buildWindowsAuthorityHelper({ ...process.env, PROPR_WINDOWS_AUTHORITY_TEST_COMPILER_FAULT: fault }), + error => error instanceof Error + && error.message === `Windows authority helper build failed [win-authority:BUILD_COMPILER:${substage}]` + && !error.message.includes('\\') && !error.message.includes('C:'), + ); + for (const unpublished of [WINDOWS_AUTHORITY_EXECUTABLE, WINDOWS_AUTHORITY_MANIFEST]) { + await assert.rejects(readFile(unpublished), error => error?.code === 'ENOENT', + `${fault} must not publish a compiler/helper artifact`); + } + } +}); + +test('native directory catalog failures expose their exact bounded offline-policy substage', windowsNativeBuildOnly, async () => { + for (const substage of [ + 'CATALOG_ENUMERATION', 'MEMBER_TAG', 'CATALOG_HASH', 'WINTRUST_POLICY', 'REVOCATION', 'CATALOG_LEASE', + 'SIGNER_PARSE', 'EXACT_PUBLISHER', 'ROOT_PIN', 'CERTIFICATE_PIN', 'SPKI_PIN', + ]) { + await assert.rejects( + buildWindowsAuthorityHelper({ + ...process.env, + PROPR_WINDOWS_AUTHORITY_TEST_DIRECTORY_PROBE_FAULT: `directory-${substage}`, + }), + error => error instanceof Error + && error.message === `Windows authority helper build failed [win-authority:BUILD_COMPILER:${substage}]` + && !error.message.includes('\\') && !error.message.includes('C:'), + ); + } +}); + +test('compiled helper output gate rejects corrupt, native-only, and wrong-machine PE files', () => { + const exact = managedPe(); + assert.deepEqual(inspectAnyCpuPe(exact), { format: 'PE32', architecture: 'anycpu', machine: 'I386', clr: true }); + const nativeOnly = Buffer.from(exact); + nativeOnly.writeUInt32LE(0, 0x98 + 96 + (14 * 8)); + assert.throws(() => inspectAnyCpuPe(nativeOnly), /BUILD_OUTPUT/); + const wrongMachine = Buffer.from(exact); + wrongMachine.writeUInt16LE(0xaa64, 0x84); + assert.throws(() => inspectAnyCpuPe(wrongMachine), /BUILD_OUTPUT/); + const required32Bit = Buffer.from(exact); + required32Bit.writeUInt32LE(0x3, 0x210); + assert.throws(() => inspectAnyCpuPe(required32Bit), /BUILD_OUTPUT/); +}); + +test('packaged helper refresh and inspection bind the exact held manifest and signed helper bytes', async () => { + // Darwin aliases /var to /private/var. Establish the fixture below the + // explicitly held canonical temp root so child proofs use one namespace. + const trustedTempRoot = await realpath(tmpdir()); + const root = await realpath(await mkdtemp(join(trustedTempRoot, 'propr-packaged-helper-'))); + const executable = join(root, 'propr-windows-authority.exe'); + const launcherPath = join(root, 'propr-windows-launcher.node'); + const bootstrapPath = join(root, 'propr-windows-bootstrap.node'); + const manifestPath = join(root, 'propr-windows-authority.manifest.json'); + try { + const bytes = managedPe(); + const launcher = Buffer.from(bytes); + launcher.writeUInt16LE(0x8664, 0x84); + await writeFile(executable, bytes); + await writeFile(launcherPath, launcher); + await writeFile(bootstrapPath, launcher); + await writeFile(manifestPath, `${JSON.stringify({ + schemaVersion: 1, + name: 'propr-windows-authority.exe', + format: 'PE32', + architecture: 'anycpu', + machine: 'I386', + clr: true, + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + sourceSha256: 'a'.repeat(64), + protocol: 'propr-windows-authority-v1', + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + launcher: { + name: 'propr-windows-launcher.node', + format: 'PE', + architecture: 'x64', + machine: 'AMD64', + size: launcher.length, + sha256: createHash('sha256').update(launcher).digest('hex'), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + bootstrap: { + name: 'propr-windows-bootstrap.node', + format: 'PE', + architecture: 'x64', + machine: 'AMD64', + size: launcher.length, + sha256: createHash('sha256').update(launcher).digest('hex'), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + compiler: { + kind: 'windows-catalog-authorized-dotnet-framework-csc-v1', + framework: 'Framework64-v4.0.30319', + signerCertificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + signerSpkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + signerRootSpkiSha256: '3'.repeat(64), + volumeSerial: '4'.repeat(16), + fileId128: '5'.repeat(32), + inputs: [ + compilerInputEvidence('csc.exe', 'b'.repeat(64)), + compilerInputEvidence('System.dll', 'c'.repeat(64)), + compilerInputEvidence('System.Web.Extensions.dll', 'd'.repeat(64)), + ], + }, + })}\n`); + await refreshPackagedWindowsAuthorityManifest(executable, manifestPath, { + PROPR_DESKTOP_PRODUCTION_RELEASE: '0', + }); + const manifest = await inspectPackagedWindowsAuthority(executable, manifestPath); + assert.equal(manifest.sha256, createHash('sha256').update(bytes).digest('hex')); + const corrupt = Buffer.from(bytes); + corrupt[700] ^= 1; + await writeFile(executable, corrupt); + await assert.rejects(inspectPackagedWindowsAuthority(executable, manifestPath), /inspection failed/); + await writeFile(executable, bytes); + const corruptLauncher = Buffer.from(launcher); + corruptLauncher[700] ^= 1; + await writeFile(launcherPath, corruptLauncher); + await assert.rejects(inspectPackagedWindowsAuthority(executable, manifestPath), /inspection failed/); + const wrongArchitecture = Buffer.from(launcher); + wrongArchitecture.writeUInt16LE(0xaa64, 0x84); + await writeFile(launcherPath, wrongArchitecture); + await assert.rejects(inspectPackagedWindowsAuthority(executable, manifestPath), /inspection failed/); + await writeFile(launcherPath, launcher); + const corruptBootstrap = Buffer.from(launcher); + corruptBootstrap[700] ^= 1; + await writeFile(bootstrapPath, corruptBootstrap); + await assert.rejects(inspectPackagedWindowsAuthority(executable, manifestPath), /inspection failed/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index ad7963f08..4276db789 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1,2 +1,6 @@ declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; declare const MAIN_WINDOW_VITE_NAME: string; +declare const __PROPR_DESKTOP_UPDATE_MANIFEST_URL__: string; +declare const __PROPR_DESKTOP_UPDATE_PUBLIC_KEY__: string; +declare const __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__: string; +declare const __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__: readonly string[]; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f83c5e3ea..461b39e47 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,6 +17,8 @@ import { validatedDevServerUrl, } from './security'; import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract'; +import { checkForSignedUpdates } from './signed-updates'; +import { handleSquirrelStartupEvent, squirrelAppUserModelId } from './squirrel-events'; import { createBrowserWindowOptions } from './window-options'; const devServerUrl = typeof MAIN_WINDOW_VITE_DEV_SERVER_URL === 'string' @@ -35,6 +37,12 @@ const deepLinkDelivery = new DeepLinkDelivery( ); let logger: DesktopLogger | null = null; let shutdownStarted = false; +const squirrelStartupHandled = process.platform === 'win32' + && handleSquirrelStartupEvent({ quit: () => app.quit() }); + +if (process.platform === 'win32') { + app.setAppUserModelId(squirrelAppUserModelId()); +} const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: Record) => logger @@ -231,8 +239,10 @@ app.on('open-url', (event, url) => { if (normalized) deliverDeepLink(normalized); }); -const hasSingleInstanceLock = app.requestSingleInstanceLock(); -if (!hasSingleInstanceLock) { +const hasSingleInstanceLock = !squirrelStartupHandled && app.requestSingleInstanceLock(); +if (squirrelStartupHandled) { + // The Squirrel event handler owns shortcut maintenance and process exit. +} else if (!hasSingleInstanceLock) { app.quit(); } else { app.on('second-instance', (_event, argv) => { @@ -249,6 +259,14 @@ if (!hasSingleInstanceLock) { void app.whenReady().then(async () => { logger = createDesktopLogger(join(app.getPath('logs'), 'desktop.jsonl')); log('info', 'desktop.app.ready', { version: app.getVersion(), platform: process.platform }); + if (process.platform === 'win32' && app.isPackaged && process.argv.includes('--propr-authority-smoke')) { + const { probePackagedWindowsAuthorityHelper } = await import('./windows-update-authority'); + const stage = await probePackagedWindowsAuthorityHelper(join(process.resourcesPath, 'windows-authority')); + if (stage !== 'READY') throw new Error(`Installed Windows authority failed at ${stage}`); + log('info', 'desktop.windows_authority.ready', { stage }); + app.exit(0); + return; + } configureSessionSecurity(); configurePackagedRendererProtocol(); @@ -280,6 +298,34 @@ if (!hasSingleInstanceLock) { mainWindow = await createMainWindow(); deepLinkDelivery.setWindow(mainWindow); + const updateConfig = __PROPR_DESKTOP_UPDATE_MANIFEST_URL__ + ? { + manifestUrl: __PROPR_DESKTOP_UPDATE_MANIFEST_URL__, + publicKey: __PROPR_DESKTOP_UPDATE_PUBLIC_KEY__, + signingIdentity: __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__, + windowsSignerPins: __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__, + } + : undefined; + if (app.isPackaged && updateConfig && process.env.PROPR_DESKTOP_SMOKE_TEST !== '1') { + const runUpdateCheck = () => { + void checkForSignedUpdates({ + config: updateConfig, + currentVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + request: (url, init) => net.fetch(url, init), + cacheDirectory: join(app.getPath('userData'), 'verified-updates'), + }).then(result => log('info', 'desktop.update.check_complete', { result })) + .catch(() => log('error', 'desktop.update.check_failed')); + }; + // Squirrel holds an installer lock briefly on Windows first run. + if (process.platform === 'win32' && process.argv.includes('--squirrel-firstrun')) { + setTimeout(runUpdateCheck, 10_000); + } else { + runUpdateCheck(); + } + } + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { void createMainWindow().then(window => { diff --git a/apps/desktop/src/native/propr-windows-authority.cs b/apps/desktop/src/native/propr-windows-authority.cs new file mode 100644 index 000000000..0c1727a20 --- /dev/null +++ b/apps/desktop/src/native/propr-windows-authority.cs @@ -0,0 +1,1106 @@ +// Strict UTF-8 source; the build gate rejects invalid byte sequences. +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; +using System.Text; +using System.Web.Script.Serialization; +using Microsoft.Win32.SafeHandles; + +public sealed class BrokerFailure : Exception { + public readonly string Code; + public readonly int Scenario; + public BrokerFailure(string code, int scenario) : base(code) { Code = code; Scenario = scenario; } +} + +public sealed class InspectionResult { + public int version = 1; + public string type = "inspection"; + public string volumeSerial; + public string fileId128; + public bool directory; + public string links; + public string size; + public string reparseTag; + public string ownerSid; + public bool daclProtected; + public string aceCount; + public string inheritedWriteAces; + public string broadWriteAces; + public string sha256; + public string sha1; +} + +public sealed class SecurityResult { + public string ownerSid; + public bool daclProtected; + public int aceCount; +} + +public static class ProprUpdateAuthority { + const uint DELETE = 0x00010000; + const uint READ_CONTROL = 0x00020000; + const uint GENERIC_READ = 0x80000000; + const uint FILE_READ_ATTRIBUTES = 0x00000080; + const uint FILE_SHARE_READ = 0x00000001; + const uint FILE_SHARE_WRITE = 0x00000002; + const uint FILE_SHARE_DELETE = 0x00000004; + const uint OPEN_EXISTING = 3; + const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + const uint ERROR_SHARING_VIOLATION = 32; + const uint FILE_BEGIN = 0; + const int FileStandardInfo = 1; + const int FileAttributeTagInfo = 9; + const int FileIdInfo = 18; + const int SE_FILE_OBJECT = 1; + const int OWNER_SECURITY_INFORMATION = 0x00000001; + const int DACL_SECURITY_INFORMATION = 0x00000004; + const int WRITE_AUTHORITY = unchecked((int)0x500D0156); + const int MAX_SECURITY_DESCRIPTOR = 65536; + const int MAX_READ = 1048576; + const int MAX_REQUEST = 16384; + const int MAX_JSON = 2097152; + const int MAX_FRAMES = 8192; + const long MAX_INPUT = 67108864L; + static readonly string CURRENT_USER_SID = WindowsIdentity.GetCurrent(TokenAccessLevels.Query).User.Value; + const string MICROSOFT_CATALOG_CERTIFICATE_SHA256 = "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de"; + const string MICROSOFT_CATALOG_SPKI_SHA256 = "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1"; + const string MICROSOFT_COMPILER_CATALOG = "Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat"; + const string MICROSOFT_COMPILER_CATALOG_SHA256 = "f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef"; + const string MICROSOFT_COMPILER_CATALOG_ARM64 = "Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat"; + const string MICROSOFT_COMPILER_CATALOG_ARM64_SHA256 = "fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85"; + static readonly UTF8Encoding STRICT_UTF8 = new UTF8Encoding(false, true); + static readonly JavaScriptSerializer JSON = new JavaScriptSerializer { MaxJsonLength = MAX_JSON }; + static readonly Stream OUTPUT = Console.OpenStandardOutput(); + static SafeFileHandle IMAGE_LEASE; + static string IMAGE_VOLUME; + static string IMAGE_FILE_ID; + static string IMAGE_SHA256; + + [StructLayout(LayoutKind.Sequential)] + struct FILE_STANDARD_INFO { + public long AllocationSize; + public long EndOfFile; + public uint NumberOfLinks; + [MarshalAs(UnmanagedType.U1)] public bool DeletePending; + [MarshalAs(UnmanagedType.U1)] public bool Directory; + } + + [StructLayout(LayoutKind.Sequential)] + struct FILE_ATTRIBUTE_TAG_INFO { public uint FileAttributes; public uint ReparseTag; } + + [StructLayout(LayoutKind.Sequential)] + struct FILE_ID_INFO { + public ulong VolumeSerialNumber; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] FileId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, + uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetFileInformationByHandleEx(SafeFileHandle handle, int infoClass, + IntPtr information, uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetFilePointerEx(SafeFileHandle handle, long distance, out long position, uint method); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool ReadFile(SafeFileHandle handle, byte[] buffer, uint requested, out uint read, IntPtr overlapped); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern uint GetSecurityInfo(SafeFileHandle handle, int objectType, int securityInfo, + out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr descriptor); + + [DllImport("kernel32.dll")] + static extern IntPtr LocalFree(IntPtr memory); + + [DllImport("wintrust.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + static extern int WinVerifyTrust(IntPtr window, [In] ref Guid action, IntPtr data); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct WINTRUST_FILE_INFO { + public uint cbStruct; + public string pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct WINTRUST_DATA { + public uint cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public uint dwUIChoice; + public uint fdwRevocationChecks; + public uint dwUnionChoice; + public IntPtr pFile; + public uint dwStateAction; + public IntPtr hWVTStateData; + public string pwszURLReference; + public uint dwProvFlags; + public uint dwUIContext; + public IntPtr pSignatureSettings; + } + + [DllImport("advapi32.dll")] + static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + + static T ReadInfo(SafeFileHandle handle, int infoClass, string code, int scenario) where T : struct { + int size = Marshal.SizeOf(typeof(T)); + IntPtr memory = Marshal.AllocHGlobal(size); + try { + if (!GetFileInformationByHandleEx(handle, infoClass, memory, (uint)size)) { + throw new BrokerFailure(code, scenario); + } + return (T)Marshal.PtrToStructure(memory, typeof(T)); + } finally { Marshal.FreeHGlobal(memory); } + } + + static SecurityResult VerifySecurity(SafeFileHandle handle) { + IntPtr owner, group, dacl, sacl, descriptor; + uint error = GetSecurityInfo(handle, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + out owner, out group, out dacl, out sacl, out descriptor); + if (error != 0 || descriptor == IntPtr.Zero) throw new BrokerFailure("owner_sid", 6); + try { + int length = checked((int)GetSecurityDescriptorLength(descriptor)); + if (length <= 0 || length > MAX_SECURITY_DESCRIPTOR) throw new BrokerFailure("owner_sid", 6); + byte[] bytes = new byte[length]; + Marshal.Copy(descriptor, bytes, 0, length); + RawSecurityDescriptor security = new RawSecurityDescriptor(bytes, 0); + SecurityIdentifier current = new SecurityIdentifier(CURRENT_USER_SID); + if (security.Owner == null || !security.Owner.Equals(current)) { + throw new BrokerFailure("owner_sid", 6); + } + if ((security.ControlFlags & ControlFlags.DiscretionaryAclProtected) == 0 + || security.DiscretionaryAcl == null) { + throw new BrokerFailure("dacl_protection", 7); + } + SecurityIdentifier system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + SecurityIdentifier administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + int aceCount = 0; + int priorOrder = -1; + foreach (GenericAce generic in security.DiscretionaryAcl) { + aceCount++; + if ((generic.AceFlags & AceFlags.Inherited) != 0) throw new BrokerFailure("dacl_ace", 8); + QualifiedAce qualified = generic as QualifiedAce; + KnownAce known = generic as KnownAce; + if (qualified == null || known == null || known.SecurityIdentifier == null + || (qualified.AceQualifier != AceQualifier.AccessAllowed + && qualified.AceQualifier != AceQualifier.AccessDenied)) { + throw new BrokerFailure("dacl_ace", 8); + } + bool allowed = qualified.AceQualifier == AceQualifier.AccessAllowed; + int order = allowed ? 1 : 0; + if (order < priorOrder) throw new BrokerFailure("dacl_ace", 8); + priorOrder = order; + SecurityIdentifier sid = known.SecurityIdentifier; + bool trusted = sid != null && (sid.Equals(current) || sid.Equals(system) || sid.Equals(administrators)); + if (allowed && !trusted && (known.AccessMask & WRITE_AUTHORITY) != 0) { + throw new BrokerFailure("dacl_ace", 8); + } + } + return new SecurityResult { ownerSid = current.Value, daclProtected = true, aceCount = aceCount }; + } finally { LocalFree(descriptor); } + } + + static SafeFileHandle OpenPinned(string path, bool readBytes) { + uint access = READ_CONTROL | FILE_READ_ATTRIBUTES | (readBytes ? GENERIC_READ : 0); + SafeFileHandle handle = CreateFileW(path, access, FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (handle.IsInvalid) { + handle.Dispose(); + throw new BrokerFailure("open_handle", 2); + } + return handle; + } + + static void ProveNoShareLock(string path) { + SafeFileHandle competing = CreateFileW(path, DELETE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (!competing.IsInvalid) { + competing.Dispose(); + throw new BrokerFailure("no_share_lock", 10); + } + int error = Marshal.GetLastWin32Error(); + competing.Dispose(); + if ((uint)error != ERROR_SHARING_VIOLATION) throw new BrokerFailure("no_share_lock", 10); + } + + static byte[] ReadAt(SafeFileHandle handle, long offset, int length, string code, int scenario) { + long position; + if (!SetFilePointerEx(handle, offset, out position, FILE_BEGIN) || position != offset) { + throw new BrokerFailure(code, scenario); + } + byte[] bytes = new byte[length]; + int total = 0; + while (total < length) { + byte[] chunk = new byte[length - total]; + uint count; + if (!ReadFile(handle, chunk, (uint)chunk.Length, out count, IntPtr.Zero) || count == 0) { + throw new BrokerFailure(code, scenario); + } + Buffer.BlockCopy(chunk, 0, bytes, total, (int)count); + total += (int)count; + } + return bytes; + } + + static string[] Hash(SafeFileHandle handle, long size) { + using (SHA256 sha256 = SHA256.Create()) + using (SHA1 sha1 = SHA1.Create()) { + byte[] chunk = new byte[Math.Min(MAX_READ, (int)Math.Min(size, MAX_READ))]; + long offset = 0; + while (offset < size) { + int length = (int)Math.Min(chunk.Length, size - offset); + byte[] bytes = ReadAt(handle, offset, length, "hash_read", 11); + sha256.TransformBlock(bytes, 0, bytes.Length, null, 0); + sha1.TransformBlock(bytes, 0, bytes.Length, null, 0); + offset += bytes.Length; + } + sha256.TransformFinalBlock(new byte[0], 0, 0); + sha1.TransformFinalBlock(new byte[0], 0, 0); + return new string[] { + BitConverter.ToString(sha256.Hash).Replace("-", "").ToLowerInvariant(), + BitConverter.ToString(sha1.Hash).Replace("-", "").ToLowerInvariant() + }; + } + } + + static InspectionResult InspectHandle(SafeFileHandle handle, bool expectedDirectory, string purpose, long expectedBytes) { + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(handle, FileAttributeTagInfo, "reparse_query", 3); + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0) { + throw new BrokerFailure("reparse_point", 4); + } + FILE_STANDARD_INFO standard = ReadInfo(handle, FileStandardInfo, "type_link_size", 5); + bool setup = purpose == "setup"; + bool artifact = purpose == "artifact"; + if (standard.DeletePending || standard.Directory != expectedDirectory || (!standard.Directory && standard.NumberOfLinks != 1) + || (standard.Directory && (!setup || expectedBytes != 0)) + || (!standard.Directory && setup && (expectedBytes != 0 || standard.EndOfFile < 0 || standard.EndOfFile > 1073807360L)) + || (!standard.Directory && artifact && (expectedBytes <= 0 || standard.EndOfFile != expectedBytes)) + || (!setup && !artifact)) { + throw new BrokerFailure("type_link_size", 5); + } + SecurityResult security = VerifySecurity(handle); + FILE_ID_INFO identity = ReadInfo(handle, FileIdInfo, "file_id_info", 9); + byte[] fileId = identity.FileId; + if (fileId == null || fileId.Length != 16) throw new BrokerFailure("file_id_info", 9); + InspectionResult result = new InspectionResult { + volumeSerial = identity.VolumeSerialNumber.ToString("x16"), + fileId128 = BitConverter.ToString(fileId).Replace("-", "").ToLowerInvariant(), + directory = standard.Directory, + links = standard.NumberOfLinks.ToString(), + size = standard.EndOfFile.ToString(), + reparseTag = attributes.ReparseTag.ToString("x8"), + ownerSid = security.ownerSid, + daclProtected = security.daclProtected, + aceCount = security.aceCount.ToString(), + inheritedWriteAces = "0", + broadWriteAces = "0" + }; + if (artifact) { + string[] hashes = Hash(handle, standard.EndOfFile); + result.sha256 = hashes[0]; + result.sha1 = hashes[1]; + } + return result; + } + + static bool Same(InspectionResult left, InspectionResult right) { + return left.volumeSerial == right.volumeSerial && left.fileId128 == right.fileId128 + && left.directory == right.directory && left.links == right.links && left.size == right.size + && left.reparseTag == right.reparseTag && left.ownerSid == right.ownerSid + && left.daclProtected == right.daclProtected && left.aceCount == right.aceCount + && left.inheritedWriteAces == right.inheritedWriteAces && left.broadWriteAces == right.broadWriteAces + && left.sha256 == right.sha256 && left.sha1 == right.sha1; + } + + static string PrivateSddl() { + return "O:" + CURRENT_USER_SID + "G:" + CURRENT_USER_SID + "D:P(A;;FA;;;" + CURRENT_USER_SID + + ")(A;;FA;;;SY)(A;;FA;;;BA)"; + } + + public static InspectionResult Inspect(string path, bool expectedDirectory) { + using (SafeFileHandle handle = OpenPinned(path, false)) { + return InspectHandle(handle, expectedDirectory, "setup", 0); + } + } + + public static InspectionResult EnsureDirectory(string path) { + if (!Directory.Exists(path)) { + DirectorySecurity security = new DirectorySecurity(); + security.SetSecurityDescriptorSddlForm(PrivateSddl()); + new DirectoryInfo(path).Create(security); + } + return Inspect(path, true); + } + + public static InspectionResult ProtectDirectory(string path) { + DirectorySecurity security = new DirectorySecurity(); + security.SetSecurityDescriptorSddlForm(PrivateSddl()); + Directory.SetAccessControl(path, security); + return Inspect(path, true); + } + + public static InspectionResult ProtectFile(string path) { + FileSecurity security = new FileSecurity(); + security.SetSecurityDescriptorSddlForm(PrivateSddl()); + File.SetAccessControl(path, security); + return Inspect(path, false); + } + + public sealed class HeldArtifact : IDisposable { + SafeFileHandle handle; + long expectedBytes; + string purpose; + InspectionResult initial; + + public HeldArtifact(string path, long exactBytes, string expectedVolumeSerial, string expectedFileId128, + string purpose, string expectedSha256) { + expectedBytes = exactBytes; + this.purpose = purpose; + handle = OpenPinned(path, true); + try { + initial = InspectHeld(); + if (initial.volumeSerial != expectedVolumeSerial || initial.fileId128 != expectedFileId128) { + throw new BrokerFailure("final_verify", 14); + } + if (purpose == "artifact" && initial.sha256 != expectedSha256) { + throw new BrokerFailure("hash_read", 11); + } + ProveNoShareLock(path); + } catch { + handle.Dispose(); + handle = null; + throw; + } + } + + void RequireOpen() { + if (handle == null || handle.IsClosed || handle.IsInvalid) throw new BrokerFailure("clean_shutdown", 15); + } + + public InspectionResult Initial { get { RequireOpen(); return initial; } } + + InspectionResult InspectHeld() { + InspectionResult result = InspectHandle(handle, false, purpose, expectedBytes); + // Held responses have one stable schema for setup and artifact + // capabilities. Setup policy remains bounded/non-exact, but its exact + // held bytes are still hashed for later same-handle comparisons. + if (purpose == "setup") { + string[] hashes = Hash(handle, Int64.Parse(result.size)); + result.sha256 = hashes[0]; + result.sha1 = hashes[1]; + } + return result; + } + + public byte[] Read(long offset, int length) { + RequireOpen(); + if (offset < 0 || length <= 0 || length > MAX_READ || offset + length > Int64.Parse(initial.size)) { + throw new BrokerFailure("request_protocol", 1); + } + return ReadAt(handle, offset, length, "held_read", 13); + } + + public InspectionResult Verify() { + RequireOpen(); + InspectionResult verified = InspectHeld(); + if (!Same(initial, verified)) throw new BrokerFailure("final_verify", 14); + return verified; + } + + public InspectionResult CloseVerified() { + try { return Verify(); } + finally { Dispose(); } + } + + public void Dispose() { + if (handle == null) return; + handle.Dispose(); + handle = null; + } + } + + public static HeldArtifact OpenHeld(string path, long expectedBytes, string expectedVolumeSerial, string expectedFileId128, + string purpose, string expectedSha256) { + if (expectedBytes < 0 || expectedBytes > 1073741824L || expectedVolumeSerial == null || expectedFileId128 == null + || (purpose != "setup" && purpose != "artifact") + || (purpose == "setup" && expectedBytes != 0) + || (purpose == "artifact" && (expectedBytes == 0 || (expectedSha256 != null && expectedSha256.Length != 64))) + || (purpose == "setup" && expectedSha256 != null)) { + throw new BrokerFailure("request_protocol", 1); + } + return new HeldArtifact(path, expectedBytes, expectedVolumeSerial, expectedFileId128, purpose, expectedSha256); + } + + public static void Smoke() { + string root = Path.Combine(Path.GetTempPath(), "propr-win-authority-smoke-" + Guid.NewGuid().ToString("N")); + HeldArtifact held = null; + try { + EnsureDirectory(root); + string artifact = Path.Combine(root, "smoke.bin"); + File.WriteAllBytes(artifact, new byte[] { 0x50 }); + ProtectFile(artifact); + InspectionResult setup = Inspect(artifact, false); + held = OpenHeld(artifact, 1, setup.volumeSerial, setup.fileId128, "setup", null); + if (held.Read(0, 1)[0] != 0x50) throw new BrokerFailure("held_read", 13); + held.CloseVerified(); + held = null; + File.Delete(artifact); + Directory.Delete(root); + } finally { + if (held != null) held.Dispose(); + try { if (Directory.Exists(root)) Directory.Delete(root, true); } catch { } + } + } + static readonly string[] START_FIELDS = { "version", "type", "challenge", "protocol" }; + static readonly string[] REQUEST_FIELDS = { "version", "type", "id", "operation", "purpose", "path", + "directory", "expectedBytes", "expectedVolumeSerial", "expectedFileId128", "expectedSha256", "challenge", + "barrier", "offset", "length" }; + + static Dictionary Frame(params object[] values) { + Dictionary frame = new Dictionary(); + for (int index = 0; index < values.Length; index += 2) frame[(string)values[index]] = values[index + 1]; + return frame; + } + + static void WriteFrame(Dictionary frame) { + byte[] bytes = STRICT_UTF8.GetBytes(JSON.Serialize(frame)); + if (bytes.Length <= 0 || bytes.Length > MAX_JSON) throw new BrokerFailure("output_bound", 17); + byte[] prefix = new byte[] { + (byte)((bytes.Length >> 24) & 0xff), (byte)((bytes.Length >> 16) & 0xff), + (byte)((bytes.Length >> 8) & 0xff), (byte)(bytes.Length & 0xff) + }; + OUTPUT.Write(prefix, 0, prefix.Length); + OUTPUT.Write(bytes, 0, bytes.Length); + OUTPUT.Flush(); + } + + static void WriteFailure(string code, int scenario, string id) { + Dictionary frame = Frame("version", 1, "type", "error", "reason", code, "scenario", scenario); + if (!String.IsNullOrEmpty(id)) frame["id"] = id; + WriteFrame(frame); + } + + static void WriteInspection(string type, string id, string challenge, InspectionResult value) { + WriteFrame(Frame("version", 1, "type", type, "id", id, "challenge", challenge, + "volumeSerial", value.volumeSerial, "fileId128", value.fileId128, "directory", value.directory, + "links", value.links, "size", value.size, "reparseTag", value.reparseTag, "ownerSid", value.ownerSid, + "daclProtected", value.daclProtected, "aceCount", value.aceCount, + "inheritedWriteAces", value.inheritedWriteAces, "broadWriteAces", value.broadWriteAces, + "sha256", value.sha256, "sha1", value.sha1)); + } + + static bool ExactFields(Dictionary value, string[] fields) { + if (value == null || value.Count != fields.Length) return false; + foreach (string field in fields) if (!value.ContainsKey(field)) return false; + return true; + } + + static bool NullFields(Dictionary value, params string[] fields) { + foreach (string field in fields) if (!value.ContainsKey(field) || value[field] != null) return false; + return true; + } + + static string Text(Dictionary value, string field) { + object item; + return value.TryGetValue(field, out item) && item is string ? (string)item : null; + } + + static bool IsBool(Dictionary value, string field, bool expected) { + object item; + return value.TryGetValue(field, out item) && item is bool && (bool)item == expected; + } + + static long Integer(Dictionary value, string field) { + object item; + if (!value.TryGetValue(field, out item) || item == null) throw new BrokerFailure("request_protocol", 1); + try { return Convert.ToInt64(item); } catch { throw new BrokerFailure("request_protocol", 1); } + } + + static bool Hex(string value, int length) { + if (value == null || value.Length != length) return false; + foreach (char character in value) if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) return false; + return true; + } + + static string ReadFrameBounded(Stream input, ref long inputBytes) { + int first = input.ReadByte(); + if (first < 0) return null; + byte[] prefix = new byte[4]; + prefix[0] = (byte)first; + for (int index = 1; index < prefix.Length; index++) { + int next = input.ReadByte(); + if (next < 0) return throwProtocol(); + prefix[index] = (byte)next; + } + int length = (prefix[0] << 24) | (prefix[1] << 16) | (prefix[2] << 8) | prefix[3]; + if (length <= 0 || length > MAX_REQUEST || inputBytes + 4L + length > MAX_INPUT) { + throw new BrokerFailure("output_bound", 17); + } + byte[] bytes = new byte[length]; + int offset = 0; + while (offset < length) { + int read = input.Read(bytes, offset, length - offset); + if (read <= 0) return throwProtocol(); + offset += read; + } + inputBytes += 4L + length; + try { return STRICT_UTF8.GetString(bytes); } + catch { throw new BrokerFailure("request_protocol", 1); } + } + + static string throwProtocol() { throw new BrokerFailure("request_protocol", 1); } + + static Dictionary ReadObject(Stream input, ref long inputBytes) { + string line = ReadFrameBounded(input, ref inputBytes); + if (line == null) return null; + try { return JSON.Deserialize>(line); } + catch { throw new BrokerFailure("request_protocol", 1); } + } + + static BrokerFailure Innermost(Exception error) { + while (error.InnerException != null) error = error.InnerException; + return error as BrokerFailure; + } + + static string[] ManifestPins(Dictionary manifest) { + IList values = manifest["signerPins"] as IList; + if (values == null || values.Count <= 0 || values.Count > 16) throw new BrokerFailure("compile_load", 4); + string[] pins = new string[values.Count]; + string previous = null; + for (int index = 0; index < values.Count; index++) { + string pin = values[index] as string; + bool valid = pin != null && ((pin.StartsWith("certificate-sha256:", StringComparison.Ordinal) + && Hex(pin.Substring(19), 64)) || (pin.StartsWith("spki-sha256:", StringComparison.Ordinal) + && Hex(pin.Substring(12), 64))); + if (!valid || (previous != null && String.CompareOrdinal(previous, pin) >= 0)) { + throw new BrokerFailure("compile_load", 4); + } + pins[index] = pin; + previous = pin; + } + return pins; + } + + static void VerifyCompilerAttestation(Dictionary manifest) { + Dictionary compiler = manifest["compiler"] as Dictionary; + string[] fields = { "kind", "framework", "signerCertificateSha256", "signerSpkiSha256", + "signerRootSpkiSha256", "volumeSerial", "fileId128", "inputs" }; + if (compiler == null || !ExactFields(compiler, fields) + || Text(compiler, "kind") != "windows-catalog-authorized-dotnet-framework-csc-v1" + || (Text(compiler, "framework") != "Framework64-v4.0.30319" + && Text(compiler, "framework") != "Framework-v4.0.30319") + || !Hex(Text(compiler, "signerCertificateSha256"), 64) + || !Hex(Text(compiler, "signerSpkiSha256"), 64) + || !Hex(Text(compiler, "signerRootSpkiSha256"), 64) + || !Hex(Text(compiler, "volumeSerial"), 16) + || !Hex(Text(compiler, "fileId128"), 32)) throw new BrokerFailure("compile_load", 4); + IList inputs = compiler["inputs"] as IList; + string[] names = { "csc.exe", "System.dll", "System.Web.Extensions.dll" }; + if (inputs == null || inputs.Count != names.Length) throw new BrokerFailure("compile_load", 4); + for (int index = 0; index < names.Length; index++) { + Dictionary input = inputs[index] as Dictionary; + string[] inputFields = { "name", "size", "sha256", "signerCertificateSha256", "signerSpkiSha256", + "signerRootSpkiSha256", "catalogName", "catalogSha256", "catalogVolumeSerial", "catalogFileId128" }; + if (input == null || !ExactFields(input, inputFields)) throw new BrokerFailure("compile_load", 4); + long size; + try { size = Convert.ToInt64(input["size"]); } catch { throw new BrokerFailure("compile_load", 4); } + if (Text(input, "name") != names[index] || size <= 0 || size > 33554432 + || !Hex(Text(input, "sha256"), 64) + || !Hex(Text(input, "signerCertificateSha256"), 64) + || !Hex(Text(input, "signerSpkiSha256"), 64) + || !Hex(Text(input, "signerRootSpkiSha256"), 64) + || Text(input, "signerCertificateSha256") != MICROSOFT_CATALOG_CERTIFICATE_SHA256 + || Text(input, "signerSpkiSha256") != MICROSOFT_CATALOG_SPKI_SHA256 + || !((Text(launcher, "architecture") == "x64" + && Text(input, "catalogName") == MICROSOFT_COMPILER_CATALOG + && Text(input, "catalogSha256") == MICROSOFT_COMPILER_CATALOG_SHA256) + || (Text(launcher, "architecture") == "arm64" + && Text(input, "catalogName") == MICROSOFT_COMPILER_CATALOG_ARM64 + && Text(input, "catalogSha256") == MICROSOFT_COMPILER_CATALOG_ARM64_SHA256)) + || !Hex(Text(input, "catalogVolumeSerial"), 16) + || !Hex(Text(input, "catalogFileId128"), 32)) { + throw new BrokerFailure("compile_load", 4); + } + if (index == 0 && (Text(input, "signerCertificateSha256") != Text(compiler, "signerCertificateSha256") + || Text(input, "signerSpkiSha256") != Text(compiler, "signerSpkiSha256") + || Text(input, "signerRootSpkiSha256") != Text(compiler, "signerRootSpkiSha256"))) { + throw new BrokerFailure("compile_load", 4); + } + } + } + + static void Stage(int index, string name) { + Console.Error.WriteLine("PROPR_BOOTSTRAP " + index.ToString("D2") + " " + name); + Console.Error.Flush(); + if (Environment.GetEnvironmentVariable("PROPR_WINDOWS_AUTHORITY_TEST_STAGE") == name) { + throw new BrokerFailure("compile_load", index); + } + } + + static Dictionary ReadManifest(string path) { + byte[] bytes = File.ReadAllBytes(path); + if (bytes.Length <= 0 || bytes.Length > 16384 || bytes[bytes.Length - 1] != 10) { + throw new BrokerFailure("compile_load", 4); + } + string text; + try { text = STRICT_UTF8.GetString(bytes, 0, bytes.Length - 1); } + catch { throw new BrokerFailure("compile_load", 4); } + Dictionary value; + try { value = JSON.Deserialize>(text); } + catch { throw new BrokerFailure("compile_load", 4); } + string[] fields = { "schemaVersion", "name", "format", "architecture", "machine", "clr", "size", "sha256", + "sourceSha256", "protocol", "trust", "publisher", "signerPins", "signerCertificateSha256", + "signerSpkiSha256", "compiler", "bootstrap", "launcher" }; + if (!ExactFields(value, fields) || Integer(value, "schemaVersion") != 1 + || Text(value, "name") != "propr-windows-authority.exe" || Text(value, "format") != "PE32" + || Text(value, "architecture") != "anycpu" || Text(value, "machine") != "I386" + || !IsBool(value, "clr", true) || !Hex(Text(value, "sha256"), 64) + || !Hex(Text(value, "sourceSha256"), 64) || Text(value, "protocol") != "propr-windows-authority-v1" + || (Text(value, "trust") != "unsigned-validation" && Text(value, "trust") != "production-signed")) { + throw new BrokerFailure("compile_load", 4); + } + bool production = Text(value, "trust") == "production-signed"; + if (production) { + string[] pins = ManifestPins(value); + string certificatePin = "certificate-sha256:" + Text(value, "signerCertificateSha256"); + string spkiPin = "spki-sha256:" + Text(value, "signerSpkiSha256"); + if (!Hex(Text(value, "signerCertificateSha256"), 64) || !Hex(Text(value, "signerSpkiSha256"), 64) + || Array.IndexOf(pins, certificatePin) < 0 && Array.IndexOf(pins, spkiPin) < 0 + || String.IsNullOrEmpty(Text(value, "publisher"))) throw new BrokerFailure("compile_load", 4); + } else if (value["publisher"] != null || value["signerCertificateSha256"] != null + || value["signerSpkiSha256"] != null || !(value["signerPins"] is IList) + || ((IList)value["signerPins"]).Count != 0) throw new BrokerFailure("compile_load", 4); + Dictionary launcher = value["launcher"] as Dictionary; + string[] launcherFields = { "name", "format", "architecture", "machine", "size", "sha256", "trust", + "publisher", "signerPins", "signerCertificateSha256", "signerSpkiSha256" }; + if (!ExactFields(launcher, launcherFields) || Text(launcher, "name") != "propr-windows-launcher.node" + || Text(launcher, "format") != "PE" + || (Text(launcher, "architecture") != "x64" && Text(launcher, "architecture") != "arm64") + || (Text(launcher, "architecture") == "x64" ? Text(launcher, "machine") != "AMD64" + : Text(launcher, "machine") != "ARM64") + || Integer(launcher, "size") <= 0 || Integer(launcher, "size") > 4194304 + || !Hex(Text(launcher, "sha256"), 64) || Text(launcher, "trust") != Text(value, "trust") + || (launcher["publisher"] == null ? value["publisher"] != null + : Text(launcher, "publisher") != Text(value, "publisher"))) throw new BrokerFailure("compile_load", 4); + Dictionary bootstrap = value["bootstrap"] as Dictionary; + if (bootstrap == null || !ExactFields(bootstrap, launcherFields) || Text(bootstrap, "name") != "propr-windows-bootstrap.node" + || Text(bootstrap, "format") != "PE" || Text(bootstrap, "architecture") != Text(launcher, "architecture") + || Text(bootstrap, "machine") != Text(launcher, "machine") + || Integer(bootstrap, "size") <= 0 || Integer(bootstrap, "size") > 4194304 + || !Hex(Text(bootstrap, "sha256"), 64) || Text(bootstrap, "trust") != Text(value, "trust") + || (bootstrap["publisher"] == null ? value["publisher"] != null + : Text(bootstrap, "publisher") != Text(value, "publisher"))) throw new BrokerFailure("compile_load", 4); + VerifyCompilerAttestation(value); + return value; + } + + static void VerifyImageSecurity(SafeFileHandle handle, bool production) { + IntPtr owner, group, dacl, sacl, descriptor; + uint error = GetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + out owner, out group, out dacl, out sacl, out descriptor); + if (error != 0 || owner == IntPtr.Zero || dacl == IntPtr.Zero || descriptor == IntPtr.Zero) { + throw new BrokerFailure("compile_load", 6); + } + try { + if (!production) return; + int length = checked((int)GetSecurityDescriptorLength(descriptor)); + if (length <= 0 || length > MAX_SECURITY_DESCRIPTOR) throw new BrokerFailure("compile_load", 6); + byte[] bytes = new byte[length]; + Marshal.Copy(descriptor, bytes, 0, length); + RawSecurityDescriptor security = new RawSecurityDescriptor(bytes, 0); + SecurityIdentifier current = new SecurityIdentifier(CURRENT_USER_SID); + SecurityIdentifier system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + SecurityIdentifier administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + SecurityIdentifier trustedInstaller = new SecurityIdentifier( + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"); + bool ownerTrusted = security.Owner != null && (security.Owner.Equals(current) || security.Owner.Equals(system) + || security.Owner.Equals(administrators) || security.Owner.Equals(trustedInstaller)); + if (!ownerTrusted || security.DiscretionaryAcl == null) throw new BrokerFailure("compile_load", 6); + foreach (GenericAce generic in security.DiscretionaryAcl) { + QualifiedAce qualified = generic as QualifiedAce; + KnownAce known = generic as KnownAce; + if (qualified == null || known == null || qualified.AceQualifier != AceQualifier.AccessAllowed) continue; + SecurityIdentifier sid = known.SecurityIdentifier; + bool trusted = sid != null && (sid.Equals(current) || sid.Equals(system) || sid.Equals(administrators) + || sid.Equals(trustedInstaller)); + if (!trusted && (known.AccessMask & WRITE_AUTHORITY) != 0) throw new BrokerFailure("compile_load", 6); + } + } finally { LocalFree(descriptor); } + } + + static void VerifyImageAncestors(string imagePath, bool production) { + string directory = Path.GetDirectoryName(imagePath); + while (!String.IsNullOrEmpty(directory)) { + using (SafeFileHandle handle = OpenPinned(directory, false)) { + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(handle, FileAttributeTagInfo, "compile_load", 7); + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0) { + throw new BrokerFailure("compile_load", 7); + } + VerifyImageSecurity(handle, production); + } + string parent = Path.GetDirectoryName(directory); + if (String.IsNullOrEmpty(parent) || String.Equals(parent, directory, StringComparison.OrdinalIgnoreCase)) break; + directory = parent; + } + } + + static void VerifyAnyCpuPe(SafeFileHandle handle, long size) { + int headerLength = checked((int)Math.Min(size, 65536)); + byte[] bytes = ReadAt(handle, 0, headerLength, "compile_load", 8); + if (bytes.Length < 512 || bytes[0] != 0x4d || bytes[1] != 0x5a) throw new BrokerFailure("compile_load", 8); + int pe = BitConverter.ToInt32(bytes, 0x3c); + if (pe < 0x40 || pe + 248 > bytes.Length || bytes[pe] != 0x50 || bytes[pe + 1] != 0x45 + || bytes[pe + 2] != 0 || bytes[pe + 3] != 0 || BitConverter.ToUInt16(bytes, pe + 4) != 0x14c + || BitConverter.ToUInt16(bytes, pe + 24) != 0x10b) throw new BrokerFailure("compile_load", 8); + int sectionCount = BitConverter.ToUInt16(bytes, pe + 6); + int optionalSize = BitConverter.ToUInt16(bytes, pe + 20); + int clrDirectory = pe + 24 + 96 + (14 * 8); + uint clrRva = BitConverter.ToUInt32(bytes, clrDirectory); + if (sectionCount <= 0 || sectionCount > 96 || optionalSize < 224 || clrDirectory + 8 > pe + 24 + optionalSize + || clrRva == 0 || BitConverter.ToUInt32(bytes, clrDirectory + 4) < 72) { + throw new BrokerFailure("compile_load", 8); + } + int sectionTable = pe + 24 + optionalSize; + int clrOffset = -1; + for (int index = 0; index < sectionCount; index++) { + int section = sectionTable + (index * 40); + if (section + 40 > bytes.Length) throw new BrokerFailure("compile_load", 8); + uint virtualSize = BitConverter.ToUInt32(bytes, section + 8); + uint virtualAddress = BitConverter.ToUInt32(bytes, section + 12); + uint rawSize = BitConverter.ToUInt32(bytes, section + 16); + uint rawAddress = BitConverter.ToUInt32(bytes, section + 20); + uint span = Math.Max(virtualSize, rawSize); + if (clrRva >= virtualAddress && clrRva - virtualAddress < span) { + clrOffset = checked((int)(rawAddress + clrRva - virtualAddress)); + } + } + if (clrOffset < 0 || clrOffset + 20 > bytes.Length) throw new BrokerFailure("compile_load", 8); + uint corFlags = BitConverter.ToUInt32(bytes, clrOffset + 16); + if ((corFlags & 0x1) == 0 || (corFlags & (0x2 | 0x10 | 0x20000)) != 0) throw new BrokerFailure("compile_load", 8); + } + + sealed class DerElement { + public int Start; + public int Content; + public int End; + } + + static DerElement ReadDer(byte[] bytes, ref int offset, int expectedTag) { + int start = offset; + if (offset >= bytes.Length || bytes[offset++] != expectedTag || offset >= bytes.Length) { + throw new BrokerFailure("compile_load", 9); + } + int length = bytes[offset++]; + if ((length & 0x80) != 0) { + int count = length & 0x7f; + if (count <= 0 || count > 4 || offset + count > bytes.Length || bytes[offset] == 0) { + throw new BrokerFailure("compile_load", 9); + } + length = 0; + for (int index = 0; index < count; index++) length = checked((length << 8) | bytes[offset++]); + if (length < 128) throw new BrokerFailure("compile_load", 9); + } + int end = checked(offset + length); + if (end > bytes.Length) throw new BrokerFailure("compile_load", 9); + return new DerElement { Start = start, Content = offset, End = end }; + } + + static byte[] SubjectPublicKeyInfo(X509Certificate2 certificate) { + byte[] raw = certificate.RawData; + int cursor = 0; + DerElement outer = ReadDer(raw, ref cursor, 0x30); + int tbsCursor = outer.Content; + DerElement tbs = ReadDer(raw, ref tbsCursor, 0x30); + int field = tbs.Content; + if (field < tbs.End && raw[field] == 0xa0) ReadDer(raw, ref field, 0xa0); + ReadDer(raw, ref field, 0x02); // serial + ReadDer(raw, ref field, 0x30); // signature algorithm + ReadDer(raw, ref field, 0x30); // issuer + ReadDer(raw, ref field, 0x30); // validity + ReadDer(raw, ref field, 0x30); // subject + DerElement spki = ReadDer(raw, ref field, 0x30); + byte[] result = new byte[spki.End - spki.Start]; + Buffer.BlockCopy(raw, spki.Start, result, 0, result.Length); + return result; + } + + static string Sha256(byte[] bytes) { + using (SHA256 hash = SHA256.Create()) { + return BitConverter.ToString(hash.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); + } + } + + static void VerifyProductionSignature(string imagePath, string publisher, string[] pins, + string expectedCertificateSha256, string expectedSpkiSha256) { + WINTRUST_FILE_INFO file = new WINTRUST_FILE_INFO { + cbStruct = (uint)Marshal.SizeOf(typeof(WINTRUST_FILE_INFO)), pcwszFilePath = imagePath, + hFile = IntPtr.Zero, pgKnownSubject = IntPtr.Zero + }; + IntPtr filePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WINTRUST_FILE_INFO))); + IntPtr dataPointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(WINTRUST_DATA))); + try { + Marshal.StructureToPtr(file, filePointer, false); + WINTRUST_DATA data = new WINTRUST_DATA { + cbStruct = (uint)Marshal.SizeOf(typeof(WINTRUST_DATA)), dwUIChoice = 2, fdwRevocationChecks = 1, + dwUnionChoice = 1, pFile = filePointer, dwStateAction = 0, dwProvFlags = 0x00000080, + dwUIContext = 0, pSignatureSettings = IntPtr.Zero + }; + Marshal.StructureToPtr(data, dataPointer, false); + Guid action = new Guid("00AAC56B-CD44-11D0-8CC2-00C04FC295EE"); + if (WinVerifyTrust(new IntPtr(-1), ref action, dataPointer) != 0) throw new BrokerFailure("compile_load", 9); + X509Certificate2 certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(imagePath)); + try { + if (!String.Equals(certificate.Subject, publisher, StringComparison.Ordinal)) throw new BrokerFailure("compile_load", 9); + DateTime now = DateTime.Now; + if (now < certificate.NotBefore || now > certificate.NotAfter) throw new BrokerFailure("compile_load", 9); + bool codeSigning = false; + foreach (X509Extension extension in certificate.Extensions) { + X509EnhancedKeyUsageExtension eku = extension as X509EnhancedKeyUsageExtension; + if (eku == null) continue; + foreach (Oid oid in eku.EnhancedKeyUsages) { + if (oid.Value == "1.3.6.1.5.5.7.3.3") codeSigning = true; + } + } + if (!codeSigning) throw new BrokerFailure("compile_load", 9); + using (X509Chain chain = new X509Chain()) { + chain.ChainPolicy.RevocationMode = X509RevocationMode.Online; + chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; + chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(15); + if (!chain.Build(certificate)) throw new BrokerFailure("compile_load", 9); + } + string certificateSha256 = Sha256(certificate.RawData); + string spkiSha256 = Sha256(SubjectPublicKeyInfo(certificate)); + if (certificateSha256 != expectedCertificateSha256 || spkiSha256 != expectedSpkiSha256 + || Array.IndexOf(pins, "certificate-sha256:" + certificateSha256) < 0 + && Array.IndexOf(pins, "spki-sha256:" + spkiSha256) < 0) { + throw new BrokerFailure("compile_load", 9); + } + } finally { certificate.Dispose(); } + } finally { + Marshal.FreeHGlobal(dataPointer); + Marshal.FreeHGlobal(filePointer); + } + } + + static void AuthenticateImage() { + Stage(4, "MANIFEST"); + string imagePath = Path.GetFullPath(Assembly.GetExecutingAssembly().Location); + if (String.IsNullOrEmpty(imagePath) || imagePath.IndexOf(':', 2) >= 0 + || !String.Equals(Path.GetFileName(imagePath), "propr-windows-authority.exe", StringComparison.OrdinalIgnoreCase)) { + throw new BrokerFailure("compile_load", 4); + } + Dictionary manifest = ReadManifest(Path.Combine(Path.GetDirectoryName(imagePath), + "propr-windows-authority.manifest.json")); + Stage(5, "HELPER_OPEN"); + SafeFileHandle handle = OpenPinned(imagePath, true); + try { + FILE_STANDARD_INFO standard = ReadInfo(handle, FileStandardInfo, "compile_load", 5); + if (standard.DeletePending || standard.Directory || standard.NumberOfLinks != 1 || standard.EndOfFile <= 0 + || standard.EndOfFile != Integer(manifest, "size")) throw new BrokerFailure("compile_load", 5); + Stage(6, "HELPER_OWNER_DACL"); + bool production = Text(manifest, "trust") == "production-signed"; + VerifyImageAncestors(imagePath, production); + VerifyImageSecurity(handle, production); + Stage(7, "HELPER_REPARSE"); + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(handle, FileAttributeTagInfo, "compile_load", 7); + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0) { + throw new BrokerFailure("compile_load", 7); + } + Stage(8, "HELPER_IDENTITY"); + FILE_ID_INFO identity = ReadInfo(handle, FileIdInfo, "compile_load", 8); + if (identity.FileId == null || identity.FileId.Length != 16) throw new BrokerFailure("compile_load", 8); + VerifyAnyCpuPe(handle, standard.EndOfFile); + IMAGE_VOLUME = identity.VolumeSerialNumber.ToString("x16"); + IMAGE_FILE_ID = BitConverter.ToString(identity.FileId).Replace("-", "").ToLowerInvariant(); + Stage(9, "HELPER_HASH"); + IMAGE_SHA256 = Hash(handle, standard.EndOfFile)[0]; + if (IMAGE_SHA256 != Text(manifest, "sha256")) throw new BrokerFailure("compile_load", 9); + if (Text(manifest, "trust") == "production-signed") VerifyProductionSignature(imagePath, + Text(manifest, "publisher"), ManifestPins(manifest), Text(manifest, "signerCertificateSha256"), + Text(manifest, "signerSpkiSha256")); + ProveNoShareLock(imagePath); + IMAGE_LEASE = handle; + handle = null; + } finally { if (handle != null) handle.Dispose(); } + } + + static void ReverifyImage() { + FILE_ID_INFO identity = ReadInfo(IMAGE_LEASE, FileIdInfo, "compile_load", 8); + FILE_STANDARD_INFO standard = ReadInfo(IMAGE_LEASE, FileStandardInfo, "compile_load", 8); + string fileId = BitConverter.ToString(identity.FileId).Replace("-", "").ToLowerInvariant(); + string hash = Hash(IMAGE_LEASE, standard.EndOfFile)[0]; + if (identity.VolumeSerialNumber.ToString("x16") != IMAGE_VOLUME || fileId != IMAGE_FILE_ID || hash != IMAGE_SHA256) { + throw new BrokerFailure("compile_load", 8); + } + string imagePath = Path.GetFullPath(Assembly.GetExecutingAssembly().Location); + using (SafeFileHandle reopened = OpenPinned(imagePath, true)) { + FILE_ATTRIBUTE_TAG_INFO attributes = ReadInfo(reopened, FileAttributeTagInfo, "compile_load", 7); + FILE_ID_INFO reopenedIdentity = ReadInfo(reopened, FileIdInfo, "compile_load", 8); + FILE_STANDARD_INFO reopenedStandard = ReadInfo(reopened, FileStandardInfo, "compile_load", 8); + string reopenedFileId = BitConverter.ToString(reopenedIdentity.FileId).Replace("-", "").ToLowerInvariant(); + string reopenedHash = Hash(reopened, reopenedStandard.EndOfFile)[0]; + if ((attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || attributes.ReparseTag != 0 + || reopenedIdentity.VolumeSerialNumber.ToString("x16") != IMAGE_VOLUME || reopenedFileId != IMAGE_FILE_ID + || reopenedStandard.NumberOfLinks != 1 || reopenedHash != IMAGE_SHA256 + || Environment.GetEnvironmentVariable("PROPR_WINDOWS_AUTHORITY_TEST_IMAGE_FAULT") == "process-image") { + throw new BrokerFailure("compile_load", 8); + } + } + } + + public static void Initialize() { Smoke(); } + + public static void Serve() { + Stream input = Console.OpenStandardInput(); + long inputBytes = 0; + int frameCount = 0; + Dictionary start; + try { + start = ReadObject(input, ref inputBytes); + if (!ExactFields(start, START_FIELDS) || Integer(start, "version") != 1 || Text(start, "type") != "start" + || Text(start, "protocol") != "propr-windows-authority-v1" || !Hex(Text(start, "challenge"), 32)) { + throw new BrokerFailure("ready_protocol", 12); + } + ReverifyImage(); + } catch (Exception error) { + BrokerFailure failure = Innermost(error); + WriteFailure(failure == null ? "ready_protocol" : failure.Code, failure == null ? 12 : failure.Scenario, ""); + return; + } + Stage(11, "READY"); + WriteFrame(Frame("version", 1, "type", "ready", "challenge", Text(start, "challenge"), + "protocol", "propr-windows-authority-v1", "maxRequestBytes", MAX_REQUEST, + "nativeSmoke", true, "compileCount", 1, "imageVolumeSerial", IMAGE_VOLUME, + "imageFileId128", IMAGE_FILE_ID, "imageSha256", IMAGE_SHA256)); + + HeldArtifact held = null; + string heldChallenge = ""; + string heldId = ""; + string heldPurpose = ""; + try { + while (true) { + Dictionary request = ReadObject(input, ref inputBytes); + if (request == null) break; + if (++frameCount > MAX_FRAMES) throw new BrokerFailure("output_bound", 17); + string id = ""; + try { + if (!ExactFields(request, REQUEST_FIELDS) || Integer(request, "version") != 1 + || Text(request, "type") != "request" || !Hex(Text(request, "id"), 32)) throwProtocol(); + id = Text(request, "id"); + string operation = Text(request, "operation"); + string purpose = Text(request, "purpose"); + if (operation == "fault-stderr" + && Environment.GetEnvironmentVariable("PROPR_WINDOWS_AUTHORITY_TEST_TRANSPORT_FAULT") == "stderr") { + Console.Error.WriteLine("PROPR_FAULT 01"); + Console.Error.Flush(); + } else if (operation == "hold") { + string path = Text(request, "path"); + if (held != null || String.IsNullOrEmpty(path) || path.Length > 8192 + || (purpose != "setup" && purpose != "artifact") || !NullFields(request, "directory", "offset", "length") + || !Hex(Text(request, "challenge"), 32) || !Hex(Text(request, "expectedVolumeSerial"), 16) + || !Hex(Text(request, "expectedFileId128"), 32) + || (purpose == "artifact" && request["expectedSha256"] != null + && !Hex(Text(request, "expectedSha256"), 64)) + || (purpose == "setup" && request["expectedSha256"] != null)) throwProtocol(); + long expectedBytes = Integer(request, "expectedBytes"); + if ((purpose == "setup" && expectedBytes != 0) || (purpose == "artifact" && expectedBytes <= 0)) throwProtocol(); + if (request["barrier"] != null) { + string barrier = Text(request, "barrier"); + if (!Hex(barrier, 32)) throwProtocol(); + WriteFrame(Frame("version", 1, "type", "before-open", "id", id, "challenge", barrier)); + Dictionary continuation = ReadObject(input, ref inputBytes); + if (++frameCount > MAX_FRAMES || !ExactFields(continuation, REQUEST_FIELDS) + || Integer(continuation, "version") != 1 || Text(continuation, "type") != "request" + || Text(continuation, "id") != id || Text(continuation, "operation") != "continue" + || Text(continuation, "purpose") != purpose || Text(continuation, "challenge") != Text(request, "challenge") + || Text(continuation, "barrier") != barrier || !NullFields(continuation, "path", "directory", "expectedBytes", + "expectedVolumeSerial", "expectedFileId128", "expectedSha256", "offset", "length")) throwProtocol(); + } + held = OpenHeld(path, expectedBytes, Text(request, "expectedVolumeSerial"), Text(request, "expectedFileId128"), + purpose, Text(request, "expectedSha256")); + heldChallenge = Text(request, "challenge"); heldId = id; heldPurpose = purpose; + WriteInspection("held", id, heldChallenge, held.Initial); + } else if (operation == "read") { + if (held == null || id != heldId || purpose != heldPurpose || Text(request, "challenge") != heldChallenge + || !NullFields(request, "path", "directory", "expectedBytes", "expectedVolumeSerial", "expectedFileId128", + "expectedSha256", "barrier")) throwProtocol(); + byte[] bytes = held.Read(Integer(request, "offset"), checked((int)Integer(request, "length"))); + WriteFrame(Frame("version", 1, "type", "bytes", "id", id, "challenge", heldChallenge, + "bytes", Convert.ToBase64String(bytes))); + } else if (operation == "verify") { + if (held == null || id != heldId || purpose != heldPurpose || Text(request, "challenge") != heldChallenge + || !Hex(Text(request, "barrier"), 32) || !NullFields(request, "path", "directory", "expectedBytes", + "expectedVolumeSerial", "expectedFileId128", "expectedSha256", "offset", "length")) throwProtocol(); + WriteInspection("verified", id, Text(request, "barrier"), held.Verify()); + } else if (operation == "close") { + if (held == null || id != heldId || purpose != heldPurpose || Text(request, "challenge") != heldChallenge + || !NullFields(request, "path", "directory", "expectedBytes", "expectedVolumeSerial", "expectedFileId128", + "expectedSha256", "barrier", "offset", "length")) throwProtocol(); + InspectionResult final = held.CloseVerified(); held = null; heldChallenge = ""; heldId = ""; heldPurpose = ""; + WriteInspection("closed", id, "", final); + } else if (held != null) { + throwProtocol(); + } else if (operation == "inspect") { + if (purpose != "setup" || request["path"] == null || !(request["directory"] is bool) + || !NullFields(request, "expectedBytes", "expectedVolumeSerial", "expectedFileId128", "expectedSha256", + "challenge", "barrier", "offset", "length")) throwProtocol(); + WriteInspection("inspection", id, "", Inspect(Text(request, "path"), (bool)request["directory"])); + } else if (operation == "ensure-directory" || operation == "protect-directory" || operation == "protect-file") { + bool expectedDirectory = operation != "protect-file"; + if (purpose != "setup" || !IsBool(request, "directory", expectedDirectory) + || !NullFields(request, "expectedBytes", "expectedVolumeSerial", "expectedFileId128", "expectedSha256", + "challenge", "barrier", "offset", "length")) throwProtocol(); + InspectionResult result = operation == "ensure-directory" ? EnsureDirectory(Text(request, "path")) + : operation == "protect-directory" ? ProtectDirectory(Text(request, "path")) : ProtectFile(Text(request, "path")); + WriteInspection("inspection", id, "", result); + } else throwProtocol(); + } catch (Exception error) { + if (held != null) { held.Dispose(); held = null; heldChallenge = ""; heldId = ""; heldPurpose = ""; } + BrokerFailure failure = Innermost(error); + WriteFailure(failure == null ? "request_protocol" : failure.Code, failure == null ? 1 : failure.Scenario, id); + } + } + } catch (Exception error) { + BrokerFailure failure = Innermost(error); + WriteFailure(failure == null ? "request_protocol" : failure.Code, failure == null ? 1 : failure.Scenario, ""); + } finally { if (held != null) held.Dispose(); } + } + + public static int Main(string[] args) { + try { + if (args == null || args.Length != 1 || args[0] != "--broker") return 64; + AuthenticateImage(); + Stage(10, "PROTOCOL_INIT"); + // The signed native parent boundary creates and owns the kill-on-close + // job and proves this process image before it resumes this entrypoint. + Initialize(); + Serve(); + return 0; + } catch (Exception error) { + BrokerFailure failure = Innermost(error); + if (failure != null) { + Console.Error.WriteLine("PROPR_FAILURE " + failure.Code + " " + failure.Scenario.ToString()); + Console.Error.Flush(); + } + return 70; + } finally { + if (IMAGE_LEASE != null) IMAGE_LEASE.Dispose(); + IMAGE_LEASE = null; + } + } +} diff --git a/apps/desktop/src/native/windows-launcher/binding.gyp b/apps/desktop/src/native/windows-launcher/binding.gyp new file mode 100644 index 000000000..2ed2cdee9 --- /dev/null +++ b/apps/desktop/src/native/windows-launcher/binding.gyp @@ -0,0 +1,49 @@ +{ + "targets": [ + { + "target_name": "propr_windows_malicious_bootstrap", + "sources": ["propr_windows_launcher.cc"], + "defines": ["NAPI_VERSION=9", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", "NOMINMAX", "_WIN32_WINNT=0x0602", "PROPR_WINDOWS_BOOTSTRAP_ONLY=1", "PROPR_WINDOWS_MALICIOUS_BOOTSTRAP=1"], + "libraries": ["-ladvapi32", "-lbcrypt", "-lcrypt32", "-lwintrust"], + "msvs_settings": { + "VCCLCompilerTool": { + "ExceptionHandling": 1, + "AdditionalOptions": ["/std:c++17", "/guard:cf", "/sdl"] + }, + "VCLinkerTool": { + "AdditionalOptions": ["/guard:cf", "/dynamicbase", "/nxcompat"] + } + } + }, + { + "target_name": "propr_windows_bootstrap", + "sources": ["propr_windows_launcher.cc"], + "defines": ["NAPI_VERSION=9", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", "NOMINMAX", "_WIN32_WINNT=0x0602", "PROPR_WINDOWS_BOOTSTRAP_ONLY=1"], + "libraries": ["-ladvapi32", "-lbcrypt", "-lcrypt32", "-lwintrust"], + "msvs_settings": { + "VCCLCompilerTool": { + "ExceptionHandling": 1, + "AdditionalOptions": ["/std:c++17", "/guard:cf", "/sdl"] + }, + "VCLinkerTool": { + "AdditionalOptions": ["/guard:cf", "/dynamicbase", "/nxcompat"] + } + } + }, + { + "target_name": "propr_windows_launcher", + "sources": ["propr_windows_launcher.cc"], + "defines": ["NAPI_VERSION=9", "UNICODE", "_UNICODE", "WIN32_LEAN_AND_MEAN", "NOMINMAX", "_WIN32_WINNT=0x0602"], + "libraries": ["-ladvapi32", "-lbcrypt", "-lcrypt32", "-lwintrust"], + "msvs_settings": { + "VCCLCompilerTool": { + "ExceptionHandling": 1, + "AdditionalOptions": ["/std:c++17", "/guard:cf", "/sdl"] + }, + "VCLinkerTool": { + "AdditionalOptions": ["/guard:cf", "/dynamicbase", "/nxcompat"] + } + } + } + ] +} diff --git a/apps/desktop/src/native/windows-launcher/propr_windows_launcher.cc b/apps/desktop/src/native/windows-launcher/propr_windows_launcher.cc new file mode 100644 index 000000000..a2299bba7 --- /dev/null +++ b/apps/desktop/src/native/windows-launcher/propr_windows_launcher.cc @@ -0,0 +1,1808 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "advapi32.lib") +#pragma comment(lib, "bcrypt.lib") +#pragma comment(lib, "crypt32.lib") +#pragma comment(lib, "wintrust.lib") + +namespace { +constexpr size_t kSystemDirectoryChars = 520; +constexpr DWORD kMaxImageBytes = 4 * 1024 * 1024; +constexpr DWORD kMaxBuildInputBytes = 32 * 1024 * 1024; +constexpr DWORD kMaxSourceBytes = 256 * 1024; +constexpr DWORD kFileIdInfo = 18; +constexpr DWORD kFileAttributeTagInfo = 9; + +struct FileIdInfo { + ULONGLONG volume; + BYTE id[16]; +}; + +struct AttributeTagInfo { + DWORD attributes; + DWORD reparse_tag; +}; + +struct LaunchLease { + HANDLE image = nullptr; + HANDLE process = nullptr; + HANDLE job = nullptr; + int stdin_fd = -1; + int stdout_fd = -1; + int stderr_fd = -1; + bool closed = false; +}; + +struct FileLeases { std::vector handles; bool closed = false; }; + +struct CatalogContextLease { + HCATADMIN admin = nullptr; + HCATINFO catalog = nullptr; + ~CatalogContextLease() { + if (catalog) CryptCATAdminReleaseCatalogContext(admin, catalog, 0); + if (admin) CryptCATAdminReleaseContext(admin, 0); + } + CatalogContextLease() = default; + CatalogContextLease(const CatalogContextLease&) = delete; + CatalogContextLease& operator=(const CatalogContextLease&) = delete; +}; + +void CloseFileLeases(FileLeases* leases) { + if (!leases || leases->closed) return; + leases->closed = true; + for (HANDLE handle : leases->handles) if (handle && handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + leases->handles.clear(); +} + +void FinalizeFileLeases(napi_env, void* data, void*) { + auto* leases = static_cast(data); + CloseFileLeases(leases); + delete leases; +} + +void CloseLease(LaunchLease* lease) { + if (!lease || lease->closed) return; + lease->closed = true; + if (lease->stdin_fd >= 0) { _close(lease->stdin_fd); lease->stdin_fd = -1; } + if (lease->stdout_fd >= 0) { _close(lease->stdout_fd); lease->stdout_fd = -1; } + if (lease->stderr_fd >= 0) { _close(lease->stderr_fd); lease->stderr_fd = -1; } + if (lease->job) { CloseHandle(lease->job); lease->job = nullptr; } + if (lease->process) { CloseHandle(lease->process); lease->process = nullptr; } + if (lease->image) { CloseHandle(lease->image); lease->image = nullptr; } +} + +void FinalizeLease(napi_env, void* data, void*) { + auto* lease = static_cast(data); + CloseLease(lease); + delete lease; +} + +bool Throw(napi_env env, const char* code) { + napi_throw_error(env, code, "Windows native authority boundary rejected the operation"); + return false; +} + +bool StringValue(napi_env env, napi_value object, const char* name, std::wstring* result) { + napi_value value; + size_t length = 0; + if (napi_get_named_property(env, object, name, &value) != napi_ok + || napi_get_value_string_utf16(env, value, nullptr, 0, &length) != napi_ok + || length == 0 || length > 32767) return false; + std::vector buffer(length + 1); + if (napi_get_value_string_utf16(env, value, buffer.data(), buffer.size(), &length) != napi_ok) return false; + result->assign(reinterpret_cast(buffer.data()), length); + return true; +} + +bool Utf8Value(napi_env env, napi_value object, const char* name, std::string* result, bool optional = false) { + napi_value value; + if (napi_get_named_property(env, object, name, &value) != napi_ok) return optional; + napi_valuetype type; + if (napi_typeof(env, value, &type) != napi_ok || type == napi_null || type == napi_undefined) return optional; + size_t length = 0; + if (type != napi_string || napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok || length > 1024) return false; + std::vector buffer(length + 1); + if (napi_get_value_string_utf8(env, value, buffer.data(), buffer.size(), &length) != napi_ok) return false; + result->assign(buffer.data(), length); + return true; +} + +bool Uint32Value(napi_env env, napi_value object, const char* name, uint32_t* result) { + napi_value value; + return napi_get_named_property(env, object, name, &value) == napi_ok + && napi_get_value_uint32(env, value, result) == napi_ok; +} + +bool BoolValue(napi_env env, napi_value object, const char* name, bool* result) { + napi_value value; + return napi_get_named_property(env, object, name, &value) == napi_ok + && napi_get_value_bool(env, value, result) == napi_ok; +} + +std::string Hex(const BYTE* bytes, size_t length) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result(length * 2, '0'); + for (size_t i = 0; i < length; ++i) { + result[i * 2] = digits[bytes[i] >> 4]; + result[i * 2 + 1] = digits[bytes[i] & 15]; + } + return result; +} + +bool Sha256Handle(HANDLE file, DWORD expected_size, std::string* result, DWORD maximum_size = kMaxImageBytes) { + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file, &size) || size.QuadPart <= 0 || size.QuadPart != expected_size + || size.QuadPart > maximum_size || SetFilePointer(file, 0, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER) return false; + BCRYPT_ALG_HANDLE algorithm = nullptr; + BCRYPT_HASH_HANDLE hash = nullptr; + DWORD object_size = 0, written = 0; + std::vector object; + std::array digest{}; + bool ok = BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) == 0 + && BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&object_size), sizeof(object_size), &written, 0) == 0; + if (ok) { object.resize(object_size); ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) == 0; } + std::array buffer{}; + DWORD total = 0; + while (ok && total < expected_size) { + DWORD read = 0; + const DWORD requested = std::min(static_cast(buffer.size()), expected_size - total); + ok = ReadFile(file, buffer.data(), requested, &read, nullptr) && read > 0 + && BCryptHashData(hash, buffer.data(), read, 0) == 0; + total += read; + } + ok = ok && total == expected_size && BCryptFinishHash(hash, digest.data(), digest.size(), 0) == 0; + if (hash) BCryptDestroyHash(hash); + if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0); + if (ok) *result = Hex(digest.data(), digest.size()); + return ok; +} + +bool FileIdentity(HANDLE file, FileIdInfo* result) { + return GetFileInformationByHandleEx(file, static_cast(kFileIdInfo), result, sizeof(*result)) != FALSE; +} + +bool SameIdentity(const FileIdInfo& left, const FileIdInfo& right) { + return left.volume == right.volume && memcmp(left.id, right.id, sizeof(left.id)) == 0; +} + +bool SameSid(PSID left, const wchar_t* right_text) { + PSID right = nullptr; + const bool same = ConvertStringSidToSidW(right_text, &right) && EqualSid(left, right); + if (right) LocalFree(right); + return same; +} + +bool CurrentUserSid(PSID owner) { + HANDLE token = nullptr; + DWORD bytes = 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false; + GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); + std::vector value(bytes); + const bool same = bytes > 0 && GetTokenInformation(token, TokenUser, value.data(), bytes, &bytes) + && EqualSid(owner, reinterpret_cast(value.data())->User.Sid); + CloseHandle(token); + return same; +} + +bool CurrentUserSidText(std::wstring* text) { + HANDLE token = nullptr; + DWORD bytes = 0; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false; + GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); + std::vector value(bytes); + LPWSTR sid_text = nullptr; + const bool ok = bytes > 0 && GetTokenInformation(token, TokenUser, value.data(), bytes, &bytes) + && ConvertSidToStringSidW(reinterpret_cast(value.data())->User.Sid, &sid_text); + if (ok) *text = sid_text; + if (sid_text) LocalFree(sid_text); + CloseHandle(token); + return ok; +} + +bool TrustedAuthoritySid(PSID sid, bool allow_current_user) { + return (allow_current_user && CurrentUserSid(sid)) || SameSid(sid, L"S-1-5-18") || SameSid(sid, L"S-1-5-32-544") + || SameSid(sid, L"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"); +} + +bool QualifiedAceSidAndMask(const ACE_HEADER* header, ACCESS_MASK* mask, PSID* sid, bool* allowed) { + if (!header || header->AceSize < sizeof(ACE_HEADER) + sizeof(ACCESS_MASK) + sizeof(DWORD)) return false; + const BYTE* bytes = reinterpret_cast(header); + switch (header->AceType) { + case ACCESS_ALLOWED_ACE_TYPE: + case ACCESS_ALLOWED_CALLBACK_ACE_TYPE: + *allowed = true; + *mask = *reinterpret_cast(bytes + sizeof(ACE_HEADER)); + *sid = const_cast(bytes + sizeof(ACE_HEADER) + sizeof(ACCESS_MASK)); + break; + case ACCESS_DENIED_ACE_TYPE: + case ACCESS_DENIED_CALLBACK_ACE_TYPE: + *allowed = false; + *mask = *reinterpret_cast(bytes + sizeof(ACE_HEADER)); + *sid = const_cast(bytes + sizeof(ACE_HEADER) + sizeof(ACCESS_MASK)); + break; + case ACCESS_ALLOWED_OBJECT_ACE_TYPE: + case ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE: + case ACCESS_DENIED_OBJECT_ACE_TYPE: + case ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE: { + *allowed = header->AceType == ACCESS_ALLOWED_OBJECT_ACE_TYPE + || header->AceType == ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE; + if (header->AceSize < sizeof(ACE_HEADER) + sizeof(ACCESS_MASK) + sizeof(DWORD)) return false; + *mask = *reinterpret_cast(bytes + sizeof(ACE_HEADER)); + const DWORD flags = *reinterpret_cast(bytes + sizeof(ACE_HEADER) + sizeof(ACCESS_MASK)); + size_t offset = sizeof(ACE_HEADER) + sizeof(ACCESS_MASK) + sizeof(DWORD); + if ((flags & ACE_OBJECT_TYPE_PRESENT) != 0) offset += sizeof(GUID); + if ((flags & ACE_INHERITED_OBJECT_TYPE_PRESENT) != 0) offset += sizeof(GUID); + if (offset >= header->AceSize) return false; + *sid = const_cast(bytes + offset); + break; + } + default: + return false; + } + const BYTE* sid_bytes = static_cast(*sid); + if (sid_bytes < bytes || sid_bytes >= bytes + header->AceSize || !IsValidSid(*sid)) return false; + const DWORD sid_bytes_length = GetLengthSid(*sid); + return sid_bytes_length > 0 && sid_bytes + sid_bytes_length <= bytes + header->AceSize; +} + +bool DangerousUntrustedAcl(PACL dacl, bool allow_current_user) { + constexpr DWORD dangerous = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES + | FILE_DELETE_CHILD | DELETE | WRITE_DAC | WRITE_OWNER | GENERIC_WRITE | GENERIC_ALL; + int prior_order = -1; + for (DWORD index = 0; index < dacl->AceCount; ++index) { + void* raw = nullptr; + if (!GetAce(dacl, index, &raw)) return true; + auto* header = static_cast(raw); + if ((header->AceFlags & INHERIT_ONLY_ACE) != 0) continue; + ACCESS_MASK mask = 0; + PSID sid = nullptr; + bool allow_ace = false; + if (!QualifiedAceSidAndMask(header, &mask, &sid, &allow_ace)) return true; + const int order = (header->AceFlags & INHERITED_ACE) != 0 + ? (allow_ace ? 3 : 2) : (allow_ace ? 1 : 0); + if (order < prior_order) return true; + prior_order = order; + // Callback and conditional allow ACEs are conservatively treated as + // effective. Evaluating their claims against only the current token would + // miss a future attacker token for which the condition becomes true. + // A named attacker SID is just as dangerous as a well-known broad group. + // Only the user and the fixed Windows authority principals may mutate an + // authenticated input while it is leased. + if (allow_ace && (mask & dangerous) != 0 && !TrustedAuthoritySid(sid, allow_current_user)) return true; + } + return false; +} + +bool SecureObjectAcl(HANDLE object, bool allow_current_user = true) { + PSECURITY_DESCRIPTOR descriptor = nullptr; + PSID owner = nullptr; + PACL dacl = nullptr; + const DWORD status = GetSecurityInfo(object, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + const bool secure = status == ERROR_SUCCESS && owner != nullptr && dacl != nullptr + && ((allow_current_user && CurrentUserSid(owner)) || SameSid(owner, L"S-1-5-18") || SameSid(owner, L"S-1-5-32-544") + || SameSid(owner, L"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464")) + && !DangerousUntrustedAcl(dacl, allow_current_user); + if (descriptor) LocalFree(descriptor); + return secure; +} + +bool SecureRegularFile(HANDLE file, DWORD expected_size, FileIdInfo* identity, bool require_protected = true, + bool allow_current_user = true) { + AttributeTagInfo tag{}; + BY_HANDLE_FILE_INFORMATION basic{}; + if (!GetFileInformationByHandle(file, &basic) + || !GetFileInformationByHandleEx(file, static_cast(kFileAttributeTagInfo), &tag, sizeof(tag)) + || !FileIdentity(file, identity) || (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0 + || (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || tag.reparse_tag != 0 + || basic.nNumberOfLinks != 1 || basic.nFileSizeHigh != 0 || basic.nFileSizeLow != expected_size) return false; + PSECURITY_DESCRIPTOR descriptor = nullptr; + PSID owner = nullptr; + PACL dacl = nullptr; + const DWORD status = GetSecurityInfo(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + bool secure = status == ERROR_SUCCESS && owner != nullptr && dacl != nullptr && SecureObjectAcl(file, allow_current_user); + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + secure = secure && GetSecurityDescriptorControl(descriptor, &control, &revision) + && (!require_protected || (control & SE_DACL_PROTECTED) != 0); + if (descriptor) LocalFree(descriptor); + return secure; +} + +bool SecureServicedSystemFile(HANDLE file, DWORD expected_size, FileIdInfo* identity) { + AttributeTagInfo tag{}; + BY_HANDLE_FILE_INFORMATION basic{}; + return expected_size > 0 && expected_size <= kMaxBuildInputBytes + && GetFileInformationByHandle(file, &basic) + && GetFileInformationByHandleEx(file, static_cast(kFileAttributeTagInfo), &tag, sizeof(tag)) + && FileIdentity(file, identity) && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 + && (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 && tag.reparse_tag == 0 + && basic.nNumberOfLinks >= 1 && basic.nFileSizeHigh == 0 && basic.nFileSizeLow == expected_size + && SecureObjectAcl(file, false); +} + +bool VerifyTrust(const std::wstring& path, HANDLE held) { + WINTRUST_FILE_INFO file{}; + file.cbStruct = sizeof(file); + file.pcwszFilePath = path.c_str(); + file.hFile = held; + WINTRUST_DATA data{}; + data.cbStruct = sizeof(data); + data.dwUIChoice = WTD_UI_NONE; + data.fdwRevocationChecks = WTD_REVOKE_NONE; + data.dwUnionChoice = WTD_CHOICE_FILE; + data.pFile = &file; + data.dwStateAction = WTD_STATEACTION_VERIFY; + data.dwProvFlags = WTD_REVOCATION_CHECK_NONE | WTD_CACHE_ONLY_URL_RETRIEVAL; + GUID policy = WINTRUST_ACTION_GENERIC_VERIFY_V2; + const LONG status = WinVerifyTrust(nullptr, &policy, &data); + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(nullptr, &policy, &data); + return status == ERROR_SUCCESS; +} + +bool Sha256Bytes(const BYTE* bytes, DWORD length, std::string* result) { + BCRYPT_ALG_HANDLE algorithm = nullptr; + BCRYPT_HASH_HANDLE hash = nullptr; + DWORD object_size = 0, written = 0; + std::vector object; + std::array digest{}; + bool ok = BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) == 0 + && BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&object_size), sizeof(object_size), &written, 0) == 0; + if (ok) { object.resize(object_size); ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) == 0; } + ok = ok && BCryptHashData(hash, const_cast(bytes), length, 0) == 0 + && BCryptFinishHash(hash, digest.data(), digest.size(), 0) == 0; + if (hash) BCryptDestroyHash(hash); + if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0); + if (ok) *result = Hex(digest.data(), digest.size()); + return ok; +} + +enum class SignerContent { + EmbeddedPe, + StandaloneCatalog, +}; + +enum class CatalogFailure { + None, + Enumeration, + MemberTag, + CatalogHash, + WinTrustPolicy, + Revocation, + CatalogLease, + SignerParse, + ExactPublisher, + RootPin, + CertificatePin, + SpkiPin, +}; + +const char* CatalogFailureCode(CatalogFailure failure) { + switch (failure) { + case CatalogFailure::Enumeration: return "CATALOG_ENUMERATION"; + case CatalogFailure::MemberTag: return "MEMBER_TAG"; + case CatalogFailure::CatalogHash: return "CATALOG_HASH"; + case CatalogFailure::WinTrustPolicy: return "WINTRUST_POLICY"; + case CatalogFailure::Revocation: return "REVOCATION"; + case CatalogFailure::CatalogLease: return "CATALOG_LEASE"; + case CatalogFailure::SignerParse: return "SIGNER_PARSE"; + case CatalogFailure::ExactPublisher: return "EXACT_PUBLISHER"; + case CatalogFailure::RootPin: return "ROOT_PIN"; + case CatalogFailure::CertificatePin: return "CERTIFICATE_PIN"; + case CatalogFailure::SpkiPin: return "SPKI_PIN"; + default: return "SIGNER_CATALOG"; + } +} + +bool RevocationFailure(LONG status) { + return status == CERT_E_REVOKED || status == CRYPT_E_REVOKED + || status == CRYPT_E_REVOCATION_OFFLINE || status == CERT_E_REVOCATION_FAILURE; +} + +bool ReadHeldBytes(HANDLE held, DWORD maximum, std::vector* bytes) { + LARGE_INTEGER size{}; + if (!GetFileSizeEx(held, &size) || size.QuadPart <= 0 || size.QuadPart > maximum + || SetFilePointer(held, 0, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER) return false; + bytes->resize(static_cast(size.QuadPart)); + DWORD total = 0; + while (total < bytes->size()) { + DWORD read = 0; + const DWORD requested = std::min(64 * 1024, static_cast(bytes->size()) - total); + if (!ReadFile(held, bytes->data() + total, requested, &read, nullptr) || read == 0) return false; + total += read; + } + return total == bytes->size(); +} + +bool SignerEvidence(HANDLE held, SignerContent expected_content, std::wstring* publisher, + std::string* certificate_hash, std::string* spki_hash, std::string* root_spki_hash = nullptr, + DWORD* chain_errors = nullptr) { + HCERTSTORE store = nullptr; + HCRYPTMSG message = nullptr; + DWORD encoding = 0, content = 0, format = 0; + const DWORD content_flag = expected_content == SignerContent::EmbeddedPe + ? CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED : CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED; + const DWORD required_content = expected_content == SignerContent::EmbeddedPe + ? CERT_QUERY_CONTENT_PKCS7_SIGNED_EMBED : CERT_QUERY_CONTENT_PKCS7_SIGNED; + std::vector exact_bytes; + CRYPT_DATA_BLOB blob{}; + const bool read = ReadHeldBytes(held, kMaxBuildInputBytes, &exact_bytes); + if (read) { + blob.cbData = static_cast(exact_bytes.size()); + blob.pbData = exact_bytes.data(); + } + if (!read || !CryptQueryObject(CERT_QUERY_OBJECT_BLOB, &blob, content_flag, + CERT_QUERY_FORMAT_FLAG_BINARY, 0, &encoding, &content, &format, &store, &message, nullptr) + || content != required_content || format != CERT_QUERY_FORMAT_BINARY) return false; + DWORD bytes = 0; + bool ok = CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, nullptr, &bytes) != FALSE; + std::vector signer(bytes); + ok = ok && CryptMsgGetParam(message, CMSG_SIGNER_INFO_PARAM, 0, signer.data(), &bytes); + PCCERT_CONTEXT certificate = nullptr; + if (ok) { + auto* info = reinterpret_cast(signer.data()); + CERT_INFO wanted{}; + wanted.Issuer = info->Issuer; + wanted.SerialNumber = info->SerialNumber; + certificate = CertFindCertificateInStore(store, encoding, 0, CERT_FIND_SUBJECT_CERT, &wanted, nullptr); + ok = certificate != nullptr; + } + if (ok) { + std::array name{}; + const DWORD name_length = CertNameToStrW(certificate->dwCertEncodingType, &certificate->pCertInfo->Subject, + CERT_X500_NAME_STR, name.data(), static_cast(name.size())); + *publisher = name_length > 1 && name_length <= name.size() ? std::wstring(name.data(), name_length - 1) : L""; + BYTE* encoded = nullptr; + DWORD encoded_bytes = 0; + ok = !publisher->empty() && Sha256Bytes(certificate->pbCertEncoded, certificate->cbCertEncoded, certificate_hash) + && CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, &certificate->pCertInfo->SubjectPublicKeyInfo, + CRYPT_ENCODE_ALLOC_FLAG, nullptr, &encoded, &encoded_bytes) + && Sha256Bytes(encoded, encoded_bytes, spki_hash); + if (encoded) LocalFree(encoded); + if (ok) { + CERT_CHAIN_PARA parameters{}; + parameters.cbSize = sizeof(parameters); + PCCERT_CHAIN_CONTEXT chain = nullptr; + // Catalogs in the canonical CatRoot store are the locally authoritative + // Windows servicing statement. Never turn a hosted build into an online + // revocation request: cached revocation is still enforced and an + // explicitly revoked or otherwise untrusted chain remains fatal. + ok = CertGetCertificateChain(nullptr, certificate, nullptr, store, ¶meters, + CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT | CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, + nullptr, &chain) + && chain && chain->cChain >= 1 && chain->rgpChain[0]->cElement >= 2; + if (ok) { + const DWORD errors = chain->TrustStatus.dwErrorStatus; + if (chain_errors) *chain_errors = errors; + // A locally installed OS catalog remains usable without network or a + // warmed revocation cache. Known revocation and every other chain + // trust error are fatal; only an unavailable offline response is + // tolerated for this canonical servicing catalog. + const DWORD offline_only = CERT_TRUST_REVOCATION_STATUS_UNKNOWN | CERT_TRUST_IS_OFFLINE_REVOCATION; + ok = (errors & ~offline_only) == CERT_TRUST_NO_ERROR; + } + if (ok && root_spki_hash) { + PCCERT_CONTEXT root = chain->rgpChain[0]->rgpElement[chain->rgpChain[0]->cElement - 1]->pCertContext; + BYTE* root_encoded = nullptr; + DWORD root_bytes = 0; + ok = CryptEncodeObjectEx(X509_ASN_ENCODING, X509_PUBLIC_KEY_INFO, &root->pCertInfo->SubjectPublicKeyInfo, + CRYPT_ENCODE_ALLOC_FLAG, nullptr, &root_encoded, &root_bytes) + && Sha256Bytes(root_encoded, root_bytes, root_spki_hash); + if (root_encoded) LocalFree(root_encoded); + } + if (chain) CertFreeCertificateChain(chain); + } + } + if (certificate) CertFreeCertificateContext(certificate); + if (message) CryptMsgClose(message); + if (store) CertCloseStore(store, 0); + return ok; +} + +bool VerifyPinnedSignature(const std::wstring& path, HANDLE held, const std::string& expected_publisher, + const std::string& expected_certificate, const std::string& expected_spki) { + if (!VerifyTrust(path, held) || expected_publisher.empty() + || expected_certificate.size() != 64 || expected_spki.size() != 64) return false; + std::wstring publisher; + std::string certificate, spki; + std::wstring expected(expected_publisher.begin(), expected_publisher.end()); + return SignerEvidence(held, SignerContent::EmbeddedPe, &publisher, &certificate, &spki) + && publisher == expected && certificate == expected_certificate && spki == expected_spki; +} + +bool PinnedMicrosoftRoot(const std::string& root_spki) { + return root_spki == "02376d0908ac23041cc7d666d9daf192554f7fc36317aa9cb800908616b28af8" + || root_spki == "c9905b0ee01202293ca026e64f08412442c5504c06e44ca7e9726d61f20e4089" + || root_spki == "b2f7298b52bf2c3cac4ddfe72de4d682ac58957595982f2b62301af597c699c5"; +} + +std::wstring SystemWindowsDirectory(); + +bool ExactMicrosoftSystemPublisher(const std::wstring& publisher) { + // CertNameToStrW(CERT_X500_NAME_STR) canonical subjects issued for the + // Windows and .NET inbox payload catalogs. Substring matching would allow a + // same-root leaf with an attacker-controlled Microsoft-looking CN. + return publisher == L"CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + || publisher == L"CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + || publisher == L"CN=Microsoft Windows, O=Microsoft Corporation, C=US" + || publisher == L"CN=Microsoft Corporation, O=Microsoft Corporation, C=US"; +} + +struct MicrosoftCatalogPolicyEntry { + const wchar_t* member_name; + const wchar_t* catalog_name; + const char* certificate_sha256; + const char* spki_sha256; + const char* catalog_sha256; +}; + +// Reviewed Windows Server 2025 x64 and Windows 11 25H2 ARM64 servicing policy. +// These are byte identities, not values learned from CryptCATAdmin on the +// current host. A servicing rotation is intentionally fail-closed until this +// application policy changes. +constexpr std::array kMicrosoftCatalogPolicy{{ + {L"csc.exe", L"Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat", + "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de", + "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1", + "f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef"}, + {L"System.dll", L"Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat", + "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de", + "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1", + "f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef"}, + {L"System.Web.Extensions.dll", L"Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat", + "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de", + "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1", + "f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef"}, + {L"powershell.exe", L"Microsoft-Windows-PowerShell-ServerCore-Package~31bf3856ad364e35~amd64~~10.0.26100.32230.cat", + "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de", + "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1", + "2d2ac25e4f3cc782a886422964dffc851a66af354220923d96153738867d7866"}, + {L"csc.exe", L"Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat", + "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de", + "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1", + "fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85"}, + {L"System.dll", L"Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat", + "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de", + "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1", + "fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85"}, + {L"System.Web.Extensions.dll", L"Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat", + "1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de", + "a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1", + "fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85"}, + {L"powershell.exe", L"Microsoft-Windows-Client-Features-Package02~31bf3856ad364e35~arm64~~10.0.26100.1.cat", + "ce08760345bd5a18aa9091e6f083522ad593bd42f587699e025afd55be589334", + "130dc613f271c90adf66157a030391c404f1e4ca21ef8261ac914fc615298b62", + "08150f5768c0780ab94d998a4302718fd1a69d6e54220a057f2d16f691a4582c"}, +}}; + +const wchar_t* BaseName(const std::wstring& path) { + const size_t slash = path.find_last_of(L"\\/"); + return path.c_str() + (slash == std::wstring::npos ? 0 : slash + 1); +} + +bool ApprovedMicrosoftCatalog(const std::wstring& member_path, const std::wstring& catalog_path, + const std::string& certificate, const std::string& spki, const std::string& catalog_sha256) { + const wchar_t* member = BaseName(member_path); + const wchar_t* catalog = BaseName(catalog_path); + return std::any_of(kMicrosoftCatalogPolicy.begin(), kMicrosoftCatalogPolicy.end(), + [&](const MicrosoftCatalogPolicyEntry& approved) { + return _wcsicmp(member, approved.member_name) == 0 && _wcsicmp(catalog, approved.catalog_name) == 0 + && certificate == approved.certificate_sha256 && spki == approved.spki_sha256 + && catalog_sha256 == approved.catalog_sha256; + }); +} + +bool CanonicalMicrosoftCatalog(const std::wstring& path, std::string* sha256, FileIdInfo* identity, + HANDLE* held_catalog) { + const std::wstring windows = SystemWindowsDirectory(); + const std::wstring catalog_root = windows + + L"\\System32\\CatRoot\\{F750E6C3-38EE-11D1-85E5-00C04FC295EE}\\"; + if (windows.empty() || path.size() <= catalog_root.size() + || _wcsnicmp(path.c_str(), catalog_root.c_str(), catalog_root.size()) != 0 + || path.find(L'\\', catalog_root.size()) != std::wstring::npos) return false; + HANDLE catalog = CreateFileW(path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + LARGE_INTEGER size{}; + std::array final_path{}; + const DWORD final_length = catalog == INVALID_HANDLE_VALUE ? 0 + : GetFinalPathNameByHandleW(catalog, final_path.data(), static_cast(final_path.size()), + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + const std::wstring expected_final = L"\\\\?\\" + path; + const bool valid = catalog != INVALID_HANDLE_VALUE && GetFileSizeEx(catalog, &size) + && size.QuadPart > 0 && size.QuadPart <= kMaxBuildInputBytes + && final_length > 0 && final_length < final_path.size() + && _wcsicmp(final_path.data(), expected_final.c_str()) == 0 + && SecureServicedSystemFile(catalog, static_cast(size.QuadPart), identity) + && Sha256Handle(catalog, static_cast(size.QuadPart), sha256, kMaxBuildInputBytes); + if (valid) *held_catalog = catalog; + else if (catalog != INVALID_HANDLE_VALUE) CloseHandle(catalog); + return valid; +} + +bool VerifyCatalogTrust(const std::wstring& path, HANDLE file, std::wstring* catalog_path, + std::string* catalog_sha256, FileIdInfo* catalog_identity, HANDLE* held_catalog, + CatalogContextLease* context_lease, CatalogFailure* failure) { + *failure = CatalogFailure::Enumeration; + HCATADMIN admin = nullptr; + if (!CryptCATAdminAcquireContext2(&admin, &DRIVER_ACTION_VERIFY, BCRYPT_SHA256_ALGORITHM, nullptr, 0)) return false; + DWORD hash_bytes = 0; + bool ok = CryptCATAdminCalcHashFromFileHandle2(admin, file, &hash_bytes, nullptr, 0) != FALSE + && hash_bytes > 0 && hash_bytes <= 128; + if (!ok) *failure = CatalogFailure::CatalogHash; + std::vector hash(hash_bytes); + ok = ok && SetFilePointer(file, 0, nullptr, FILE_BEGIN) != INVALID_SET_FILE_POINTER + && CryptCATAdminCalcHashFromFileHandle2(admin, file, &hash_bytes, hash.data(), 0); + if (!ok) *failure = CatalogFailure::CatalogHash; + HCATINFO catalog = ok ? CryptCATAdminEnumCatalogFromHash(admin, hash.data(), hash_bytes, 0, nullptr) : nullptr; + CATALOG_INFO catalog_info{}; + catalog_info.cbStruct = sizeof(catalog_info); + ok = ok && catalog && CryptCATCatalogInfoFromContext(catalog, &catalog_info, 0); + std::wstring member_tag; + if (ok) { + *catalog_path = catalog_info.wszCatalogFile; + ok = CanonicalMicrosoftCatalog(*catalog_path, catalog_sha256, catalog_identity, held_catalog); + if (!ok) *failure = CatalogFailure::CatalogLease; + } + if (ok) { + const std::string lower = Hex(hash.data(), hash.size()); + member_tag.assign(lower.begin(), lower.end()); + std::transform(member_tag.begin(), member_tag.end(), member_tag.begin(), + [](wchar_t value) { return static_cast(towupper(value)); }); + if (member_tag.empty() || member_tag.size() != hash.size() * 2) { + ok = false; + *failure = CatalogFailure::MemberTag; + } + } + if (ok) { + WINTRUST_CATALOG_INFO member{}; + member.cbStruct = sizeof(member); + member.pcwszCatalogFilePath = catalog_info.wszCatalogFile; + member.pcwszMemberTag = member_tag.c_str(); + member.pcwszMemberFilePath = path.c_str(); + member.hMemberFile = file; + member.pbCalculatedFileHash = hash.data(); + member.cbCalculatedFileHash = hash_bytes; + WINTRUST_DATA data{}; + data.cbStruct = sizeof(data); + data.dwUIChoice = WTD_UI_NONE; + data.fdwRevocationChecks = WTD_REVOKE_NONE; + data.dwUnionChoice = WTD_CHOICE_CATALOG; + data.pCatalog = &member; + data.dwStateAction = WTD_STATEACTION_VERIFY; + data.dwProvFlags = WTD_REVOCATION_CHECK_NONE | WTD_CACHE_ONLY_URL_RETRIEVAL; + GUID policy = WINTRUST_ACTION_GENERIC_VERIFY_V2; + const LONG trust_status = WinVerifyTrust(nullptr, &policy, &data); + ok = trust_status == ERROR_SUCCESS; + if (!ok) *failure = RevocationFailure(trust_status) + ? CatalogFailure::Revocation : CatalogFailure::WinTrustPolicy; + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(nullptr, &policy, &data); + } + if (!ok && *held_catalog != INVALID_HANDLE_VALUE) { + CloseHandle(*held_catalog); + *held_catalog = INVALID_HANDLE_VALUE; + } + if (ok) { + context_lease->admin = admin; + context_lease->catalog = catalog; + admin = nullptr; + catalog = nullptr; + } + if (catalog) CryptCATAdminReleaseCatalogContext(admin, catalog, 0); + if (admin) CryptCATAdminReleaseContext(admin, 0); + if (ok) *failure = CatalogFailure::None; + return ok; +} + +bool VerifyMicrosoftCompilerInput(const std::wstring& path, HANDLE file, std::string* certificate, + std::string* spki, std::string* root_spki, std::string* catalog_sha256, + std::string* catalog_name, std::wstring* catalog_path, FileIdInfo* catalog_identity, + HANDLE* held_catalog, CatalogContextLease* context_lease, CatalogFailure* failure) { + // Inbox compiler/reference authorization is membership in the immutable, + // OS-serviced Windows catalog rooted at the canonical CatRoot namespace. + // An arbitrary embedded Authenticode signature, even under a Microsoft root, + // is deliberately insufficient. + std::wstring evidence_path; + const bool trusted = VerifyCatalogTrust(path, file, &evidence_path, catalog_sha256, + catalog_identity, held_catalog, context_lease, failure); + std::wstring publisher; + DWORD chain_errors = 0xffffffff; + if (!trusted) return false; + if (!SignerEvidence(*held_catalog, SignerContent::StandaloneCatalog, + &publisher, certificate, spki, root_spki, &chain_errors)) { + *failure = (chain_errors & CERT_TRUST_IS_REVOKED) != 0 + ? CatalogFailure::Revocation : chain_errors == 0xffffffff + ? CatalogFailure::SignerParse : CatalogFailure::WinTrustPolicy; + return false; + } + if (!ExactMicrosoftSystemPublisher(publisher)) { *failure = CatalogFailure::ExactPublisher; return false; } + if (!PinnedMicrosoftRoot(*root_spki)) { *failure = CatalogFailure::RootPin; return false; } + const wchar_t* member = BaseName(path); + const wchar_t* catalog = BaseName(evidence_path); + const auto same_member_and_catalog = [&](const MicrosoftCatalogPolicyEntry& approved) { + return _wcsicmp(member, approved.member_name) == 0 && _wcsicmp(catalog, approved.catalog_name) == 0; + }; + const auto matching_identity = std::find_if(kMicrosoftCatalogPolicy.begin(), kMicrosoftCatalogPolicy.end(), + same_member_and_catalog); + if (matching_identity == kMicrosoftCatalogPolicy.end()) { *failure = CatalogFailure::CatalogHash; return false; } + if (*certificate != matching_identity->certificate_sha256) { *failure = CatalogFailure::CertificatePin; return false; } + if (*spki != matching_identity->spki_sha256) { *failure = CatalogFailure::SpkiPin; return false; } + if (*catalog_sha256 != matching_identity->catalog_sha256 + || !ApprovedMicrosoftCatalog(path, evidence_path, *certificate, *spki, *catalog_sha256)) { + *failure = CatalogFailure::CatalogHash; return false; + } + const wchar_t* approved_name = BaseName(evidence_path); + catalog_name->clear(); + for (const wchar_t* cursor = approved_name; *cursor; ++cursor) { + if (*cursor > 0x7f) { *failure = CatalogFailure::CatalogHash; return false; } + catalog_name->push_back(static_cast(*cursor)); + } + *catalog_path = evidence_path; + *failure = CatalogFailure::None; + return true; +} + +bool ExpectedArchitecture(HANDLE file) { + IMAGE_DOS_HEADER dos{}; + DWORD read = 0; + if (!ReadFile(file, &dos, sizeof(dos), &read, nullptr) || read != sizeof(dos) || dos.e_magic != IMAGE_DOS_SIGNATURE + || SetFilePointer(file, dos.e_lfanew, nullptr, FILE_BEGIN) == INVALID_SET_FILE_POINTER) return false; + DWORD signature = 0; + IMAGE_FILE_HEADER header{}; + if (!ReadFile(file, &signature, sizeof(signature), &read, nullptr) || signature != IMAGE_NT_SIGNATURE + || !ReadFile(file, &header, sizeof(header), &read, nullptr)) return false; +#if defined(_M_ARM64) + return header.Machine == IMAGE_FILE_MACHINE_ARM64; +#else + return header.Machine == IMAGE_FILE_MACHINE_AMD64; +#endif +} + +std::wstring SystemWindowsDirectory() { + std::array path{}; + const UINT length = GetSystemWindowsDirectoryW(path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size() || path[0] == L'\\' || path[1] != L':') return {}; + return std::wstring(path.data(), length); +} + +bool CanonicalDirectory(const std::wstring& path, bool allow_current_user = true) { + HANDLE directory = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + AttributeTagInfo tag{}; + FileIdInfo identity{}; + const bool valid = directory != INVALID_HANDLE_VALUE + && GetFileInformationByHandleEx(directory, static_cast(kFileAttributeTagInfo), + &tag, sizeof(tag)) && FileIdentity(directory, &identity) + && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && tag.reparse_tag == 0 + && SecureObjectAcl(directory, allow_current_user); + if (directory != INVALID_HANDLE_VALUE) CloseHandle(directory); + return valid; +} + +bool ProtectPrivateBuildDirectory(const std::wstring& path) { + HANDLE directory = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + AttributeTagInfo tag{}; + PSECURITY_DESCRIPTOR current = nullptr; + PSID owner = nullptr; + std::wstring user_sid; + bool valid = directory != INVALID_HANDLE_VALUE + && GetFileInformationByHandleEx(directory, static_cast(kFileAttributeTagInfo), + &tag, sizeof(tag)) && (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 + && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && tag.reparse_tag == 0 + && GetSecurityInfo(directory, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, nullptr, nullptr, nullptr, + ¤t) == ERROR_SUCCESS && owner && CurrentUserSid(owner) && CurrentUserSidText(&user_sid); + PSECURITY_DESCRIPTOR replacement = nullptr; + PACL dacl = nullptr; + BOOL present = FALSE, defaulted = FALSE; + if (valid) { + const std::wstring sddl = L"D:P(A;OICI;FA;;;" + user_sid + + L")(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; + valid = ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, + &replacement, nullptr) && GetSecurityDescriptorDacl(replacement, &present, &dacl, &defaulted) + && present && dacl && SetSecurityInfo(directory, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, dacl, nullptr) == ERROR_SUCCESS; + } + if (replacement) LocalFree(replacement); + if (current) LocalFree(current); + if (directory != INVALID_HANDLE_VALUE) CloseHandle(directory); + return valid && CanonicalDirectory(path, true); +} + +napi_value ProbeSystemDirectory(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1) { Throw(env, "SYSTEM_PROBE"); return nullptr; } + std::string fault; + Utf8Value(env, args[0], "fault", &fault, true); + const std::wstring windows = SystemWindowsDirectory(); + if (windows.empty()) { Throw(env, "SYSTEM_PROBE"); return nullptr; } + const std::wstring powershell = windows + L"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + const bool directory_valid = CanonicalDirectory(windows, false) + && CanonicalDirectory(windows + L"\\System32", false) + && CanonicalDirectory(windows + L"\\System32\\WindowsPowerShell", false) + && CanonicalDirectory(windows + L"\\System32\\WindowsPowerShell\\v1.0", false); + if (!directory_valid) { Throw(env, "SYSTEM_DIRECTORY"); return nullptr; } + HANDLE candidate = CreateFileW(powershell.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (candidate == INVALID_HANDLE_VALUE) { Throw(env, "SYSTEM_CANDIDATE"); return nullptr; } + LARGE_INTEGER size{}; + FileIdInfo identity{}; + FileIdInfo system_catalog_identity{}; + HANDLE system_catalog = INVALID_HANDLE_VALUE; + CatalogContextLease system_catalog_context{}; + CatalogFailure catalog_failure = CatalogFailure::None; + std::string system_certificate, system_spki, system_root_spki, system_catalog_sha256, system_catalog_name; + std::wstring system_catalog_path; + std::array final_path{}; + const DWORD final_length = GetFinalPathNameByHandleW(candidate, final_path.data(), static_cast(final_path.size()), + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + const std::wstring expected_final = L"\\\\?\\" + powershell; + const bool valid = GetFileSizeEx(candidate, &size) && size.QuadPart > 0 && size.QuadPart <= kMaxImageBytes + && final_length > 0 && final_length < final_path.size() && _wcsicmp(final_path.data(), expected_final.c_str()) == 0 + && SecureServicedSystemFile(candidate, static_cast(size.QuadPart), &identity) + && VerifyMicrosoftCompilerInput(powershell, candidate, &system_certificate, &system_spki, + &system_root_spki, &system_catalog_sha256, &system_catalog_name, &system_catalog_path, + &system_catalog_identity, &system_catalog, &system_catalog_context, &catalog_failure); + if (system_catalog != INVALID_HANDLE_VALUE) CloseHandle(system_catalog); + CloseHandle(candidate); + if (!valid) { Throw(env, catalog_failure == CatalogFailure::None + ? "SYSTEM_CANDIDATE" : CatalogFailureCode(catalog_failure)); return nullptr; } + constexpr std::array diagnostic_faults{ + "CATALOG_ENUMERATION", "MEMBER_TAG", "CATALOG_HASH", "WINTRUST_POLICY", "REVOCATION", + "CATALOG_LEASE", "SIGNER_PARSE", "EXACT_PUBLISHER", "ROOT_PIN", "CERTIFICATE_PIN", "SPKI_PIN", + }; + for (const char* code : diagnostic_faults) { + if (fault == std::string("directory-") + code) { Throw(env, code); return nullptr; } + } + + std::wstring system_root_hint, windir_hint; + StringValue(env, args[0], "systemRoot", &system_root_hint); + StringValue(env, args[0], "windir", &windir_hint); + auto equal = [](const std::wstring& a, const std::wstring& b) { + return a.empty() || (a.size() == b.size() && _wcsicmp(a.c_str(), b.c_str()) == 0); + }; + if (!equal(system_root_hint, windows) || !equal(windir_hint, windows)) { Throw(env, "SYSTEM_HINT"); return nullptr; } + + void* data = nullptr; + napi_value output; + const size_t bytes = sizeof(uint16_t) + kSystemDirectoryChars * sizeof(char16_t); + if (napi_create_buffer(env, bytes, &data, &output) != napi_ok) { Throw(env, "SYSTEM_PROBE"); return nullptr; } + memset(data, 0, bytes); + *static_cast(data) = static_cast(windows.size()); + memcpy(static_cast(data) + sizeof(uint16_t), windows.data(), windows.size() * sizeof(wchar_t)); + return output; +} + +bool PipePair(HANDLE* read, HANDLE* write, bool parent_reads) { + SECURITY_ATTRIBUTES attributes{sizeof(attributes), nullptr, TRUE}; + if (!CreatePipe(read, write, &attributes, 0)) return false; + HANDLE parent = parent_reads ? *read : *write; + return SetHandleInformation(parent, HANDLE_FLAG_INHERIT, 0) != FALSE; +} + +bool MutationWasDenied(const std::wstring& path, const std::string& fault) { + if (fault.find("delete") != std::string::npos) return !DeleteFileW(path.c_str()); + if (fault.find("swap") != std::string::npos || fault.find("aba") != std::string::npos) { + const std::wstring displaced = path + L".native-barrier"; + if (!MoveFileExW(path.c_str(), displaced.c_str(), MOVEFILE_REPLACE_EXISTING)) return true; + MoveFileExW(displaced.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING); + return false; + } + if (fault.find("write") != std::string::npos) { + HANDLE writer = CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (writer == INVALID_HANDLE_VALUE) return true; + CloseHandle(writer); + return false; + } + return true; +} + +napi_value LoadVerifiedModule(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring path; + std::string expected_hash, publisher, certificate_pin, spki_pin, fault; + uint32_t expected_size = 0; + bool production = false; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "path", &path) || !Utf8Value(env, args[0], "sha256", &expected_hash) + || !Uint32Value(env, args[0], "size", &expected_size) || !BoolValue(env, args[0], "production", &production) + || expected_hash.size() != 64 || expected_size == 0 || expected_size > kMaxImageBytes) { + Throw(env, "MODULE_ARGUMENT"); return nullptr; + } + Utf8Value(env, args[0], "publisher", &publisher, true); + Utf8Value(env, args[0], "signerCertificateSha256", &certificate_pin, true); + Utf8Value(env, args[0], "signerSpkiSha256", &spki_pin, true); + Utf8Value(env, args[0], "fault", &fault, true); + + // This handle denies write/delete sharing across authentication, loader + // mapping, loaded-image comparison and N-API registration. Consequently a + // hostile DllMain/NAPI image cannot be substituted at the pre-load barrier. + HANDLE held = CreateFileW(path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + FileIdInfo held_id{}; + std::string held_hash; + const bool authenticated = held != INVALID_HANDLE_VALUE + && SecureRegularFile(held, expected_size, &held_id, false) && ExpectedArchitecture(held) + && Sha256Handle(held, expected_size, &held_hash) && held_hash == expected_hash + && (!production || VerifyPinnedSignature(path, held, publisher, certificate_pin, spki_pin)); + if (!authenticated) { + if (held != INVALID_HANDLE_VALUE) CloseHandle(held); + Throw(env, "MODULE_AUTHORITY"); return nullptr; + } + if (fault.rfind("barrier-before-module-load-", 0) == 0 && !MutationWasDenied(path, fault)) { + CloseHandle(held); Throw(env, "MODULE_BARRIER"); return nullptr; + } + + HMODULE module = LoadLibraryExW(path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); + std::array loaded_path{}; + const DWORD loaded_length = module + ? GetModuleFileNameW(module, loaded_path.data(), static_cast(loaded_path.size())) : 0; + HANDLE loaded = loaded_length > 0 && loaded_length < loaded_path.size() + ? CreateFileW(loaded_path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) + : INVALID_HANDLE_VALUE; + FileIdInfo loaded_id{}; + std::string loaded_hash; + const bool same_image = module && loaded != INVALID_HANDLE_VALUE + && SecureRegularFile(loaded, expected_size, &loaded_id, false) && SameIdentity(held_id, loaded_id) + && Sha256Handle(loaded, expected_size, &loaded_hash) && loaded_hash == held_hash; + if (loaded != INVALID_HANDLE_VALUE) CloseHandle(loaded); + if (!same_image) { + if (module) FreeLibrary(module); + CloseHandle(held); Throw(env, "MODULE_IMAGE"); return nullptr; + } + using RegisterModule = napi_value (*)(napi_env, napi_value); + auto* registration = reinterpret_cast(GetProcAddress(module, "napi_register_module_v1")); + napi_value exports; + if (!registration || napi_create_object(env, &exports) != napi_ok) { + FreeLibrary(module); CloseHandle(held); Throw(env, "MODULE_REGISTER"); return nullptr; + } + napi_value registered = registration(env, exports); + CloseHandle(held); + if (!registered) { Throw(env, "MODULE_REGISTER"); return nullptr; } + // Deliberately retain the authenticated module for the Node environment; + // unloading while exported functions remain reachable would be unsafe. + return registered; +} + +std::wstring Quote(const std::wstring& value) { + std::wstring result = L"\""; + for (wchar_t ch : value) { if (ch == L'\"') result += L'\\'; result += ch; } + return result + L"\" --broker"; +} + +napi_value Launch(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1) { Throw(env, "LAUNCH_ARGUMENT"); return nullptr; } + std::wstring path; + std::string expected_hash; + std::string fault; + std::string publisher, certificate_pin, spki_pin; + uint32_t expected_size = 0; + bool production = false; + if (!StringValue(env, args[0], "path", &path) || !Utf8Value(env, args[0], "sha256", &expected_hash) + || !Uint32Value(env, args[0], "size", &expected_size) || expected_hash.size() != 64 + || !BoolValue(env, args[0], "production", &production) + || expected_size == 0 || expected_size > kMaxImageBytes) { Throw(env, "LAUNCH_ARGUMENT"); return nullptr; } + Utf8Value(env, args[0], "fault", &fault, true); + Utf8Value(env, args[0], "publisher", &publisher, true); + Utf8Value(env, args[0], "signerCertificateSha256", &certificate_pin, true); + Utf8Value(env, args[0], "signerSpkiSha256", &spki_pin, true); + + HANDLE image = CreateFileW(path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (image == INVALID_HANDLE_VALUE) { Throw(env, "HELPER_OPEN"); return nullptr; } + FileIdInfo held_id{}; + std::string held_hash; + if (!SecureRegularFile(image, expected_size, &held_id, false) + || !Sha256Handle(image, expected_size, &held_hash) || held_hash != expected_hash + || (production && !VerifyPinnedSignature(path, image, publisher, certificate_pin, spki_pin))) { + CloseHandle(image); Throw(env, "HELPER_AUTHORITY"); return nullptr; + } + if (fault.rfind("barrier-after-hash-", 0) == 0 && !MutationWasDenied(path, fault)) { + CloseHandle(image); Throw(env, "HELPER_BARRIER"); return nullptr; + } + + HANDLE child_in_read = nullptr, parent_in_write = nullptr; + HANDLE parent_out_read = nullptr, child_out_write = nullptr; + HANDLE parent_err_read = nullptr, child_err_write = nullptr; + if (!PipePair(&child_in_read, &parent_in_write, false) + || !PipePair(&parent_out_read, &child_out_write, true) + || !PipePair(&parent_err_read, &child_err_write, true)) { + if (child_in_read) CloseHandle(child_in_read); + if (parent_in_write) CloseHandle(parent_in_write); + if (parent_out_read) CloseHandle(parent_out_read); + if (child_out_write) CloseHandle(child_out_write); + if (parent_err_read) CloseHandle(parent_err_read); + if (child_err_write) CloseHandle(child_err_write); + CloseHandle(image); Throw(env, "PIPE_CREATE"); return nullptr; + } + + SIZE_T attribute_bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_bytes); + std::vector attribute_storage(attribute_bytes); + auto* attributes = reinterpret_cast(attribute_storage.data()); + HANDLE inherited[] = {child_in_read, child_out_write, child_err_write}; + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = child_in_read; + startup.StartupInfo.hStdOutput = child_out_write; + startup.StartupInfo.hStdError = child_err_write; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process{}; + std::wstring command = Quote(path); + const std::wstring windows = SystemWindowsDirectory(); + std::wstring environment; + if (!fault.empty()) { + std::wstring wide_fault(fault.begin(), fault.end()); + if (fault == "stderr") environment += L"PROPR_WINDOWS_AUTHORITY_TEST_TRANSPORT_FAULT=stderr"; + else if (fault == "process-image") environment += L"PROPR_WINDOWS_AUTHORITY_TEST_IMAGE_FAULT=process-image"; + else environment += L"PROPR_WINDOWS_AUTHORITY_TEST_STAGE=" + wide_fault; + environment.push_back(L'\0'); + } + // CreateProcess requires a sorted Unicode environment block. The optional + // fixed PROPR_* test enum sorts before the sole production SystemRoot entry. + environment += L"SystemRoot=" + windows; + environment.push_back(L'\0'); + environment.push_back(L'\0'); + const bool attributes_initialized = InitializeProcThreadAttributeList(attributes, 1, 0, &attribute_bytes) != FALSE; + const bool precreate_barrier = fault.rfind("barrier-before-create-", 0) != 0 || MutationWasDenied(path, fault); + bool created = precreate_barrier && attributes_initialized + && UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) + && CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + environment.data(), nullptr, &startup.StartupInfo, &process); + if (attributes_initialized) DeleteProcThreadAttributeList(attributes); + CloseHandle(child_in_read); CloseHandle(child_out_write); CloseHandle(child_err_write); + if (!created) { + CloseHandle(parent_in_write); CloseHandle(parent_out_read); CloseHandle(parent_err_read); CloseHandle(image); + Throw(env, "PROCESS_CREATE"); return nullptr; + } + + HANDLE job = CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + limits.BasicLimitInformation.ActiveProcessLimit = 1; + bool proven = job && SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) + && AssignProcessToJobObject(job, process.hProcess); + if (fault == "job-assignment") proven = false; + if (fault == "extra-child" && proven) { + STARTUPINFOW extra_startup{}; + extra_startup.cb = sizeof(extra_startup); + PROCESS_INFORMATION extra{}; + std::wstring extra_command = Quote(path); + const bool extra_created = CreateProcessW(path.c_str(), extra_command.data(), nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + environment.data(), nullptr, &extra_startup, &extra); + const bool process_limit_enforced = extra_created && !AssignProcessToJobObject(job, extra.hProcess); + if (extra_created) { + TerminateProcess(extra.hProcess, 127); + CloseHandle(extra.hThread); + CloseHandle(extra.hProcess); + } + proven = process_limit_enforced; + } + if (fault.rfind("barrier-after-process-", 0) == 0 && !MutationWasDenied(path, fault)) proven = false; + std::array loaded_path{}; + DWORD loaded_length = static_cast(loaded_path.size()); + proven = proven && QueryFullProcessImageNameW(process.hProcess, 0, loaded_path.data(), &loaded_length); + HANDLE loaded = proven ? CreateFileW(loaded_path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) : INVALID_HANDLE_VALUE; + FileIdInfo loaded_id{}; + std::string loaded_hash; + proven = proven && loaded != INVALID_HANDLE_VALUE && SecureRegularFile(loaded, expected_size, &loaded_id, false) + && SameIdentity(held_id, loaded_id) && Sha256Handle(loaded, expected_size, &loaded_hash) && loaded_hash == held_hash; + if (fault == "parent-image-proof" || fault == "pipe-substitution") proven = false; + if (loaded != INVALID_HANDLE_VALUE) CloseHandle(loaded); + if (!proven || ResumeThread(process.hThread) == static_cast(-1)) { + TerminateProcess(process.hProcess, 127); CloseHandle(process.hThread); CloseHandle(process.hProcess); + if (job) CloseHandle(job); + CloseHandle(parent_in_write); CloseHandle(parent_out_read); CloseHandle(parent_err_read); CloseHandle(image); + Throw(env, proven ? "PROCESS_RESUME" : "PROCESS_IMAGE"); return nullptr; + } + CloseHandle(process.hThread); + + auto* lease = new LaunchLease(); + lease->image = image; + lease->process = process.hProcess; + lease->job = job; + lease->stdin_fd = _open_osfhandle(reinterpret_cast(parent_in_write), _O_WRONLY | _O_BINARY); + if (lease->stdin_fd >= 0) parent_in_write = nullptr; + lease->stdout_fd = _open_osfhandle(reinterpret_cast(parent_out_read), _O_RDONLY | _O_BINARY); + if (lease->stdout_fd >= 0) parent_out_read = nullptr; + lease->stderr_fd = _open_osfhandle(reinterpret_cast(parent_err_read), _O_RDONLY | _O_BINARY); + if (lease->stderr_fd >= 0) parent_err_read = nullptr; + if (lease->stdin_fd < 0 || lease->stdout_fd < 0 || lease->stderr_fd < 0) { + CloseLease(lease); + if (parent_in_write) CloseHandle(parent_in_write); + if (parent_out_read) CloseHandle(parent_out_read); + if (parent_err_read) CloseHandle(parent_err_read); + delete lease; Throw(env, "PIPE_EXPORT"); return nullptr; + } + napi_value result, external, value; + napi_create_object(env, &result); + napi_create_external(env, lease, FinalizeLease, nullptr, &external); + napi_set_named_property(env, result, "lease", external); + napi_create_int32(env, lease->stdin_fd, &value); napi_set_named_property(env, result, "stdinFd", value); + napi_create_int32(env, lease->stdout_fd, &value); napi_set_named_property(env, result, "stdoutFd", value); + napi_create_int32(env, lease->stderr_fd, &value); napi_set_named_property(env, result, "stderrFd", value); + napi_create_uint32(env, process.dwProcessId, &value); napi_set_named_property(env, result, "pid", value); + char volume[17]{}; + sprintf_s(volume, "%016llx", held_id.volume); + napi_create_string_utf8(env, volume, NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "volumeSerial", value); + napi_create_string_utf8(env, Hex(held_id.id, sizeof(held_id.id)).c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "fileId128", value); + return result; +} + +LaunchLease* LeaseArgument(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + void* data = nullptr; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || napi_get_value_external(env, args[0], &data) != napi_ok) return nullptr; + return static_cast(data); +} + +napi_value Status(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease || lease->closed || !lease->process) { Throw(env, "LEASE_CLOSED"); return nullptr; } + DWORD code = 0; + if (!GetExitCodeProcess(lease->process, &code)) { Throw(env, "PROCESS_STATUS"); return nullptr; } + napi_value result; + if (code == STILL_ACTIVE) napi_get_null(env, &result); else napi_create_uint32(env, code, &result); + return result; +} + +napi_value CloseInput(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease || lease->closed) { Throw(env, "LEASE_CLOSED"); return nullptr; } + if (lease->stdin_fd >= 0) { _close(lease->stdin_fd); lease->stdin_fd = -1; } + napi_value result; napi_get_undefined(env, &result); return result; +} + +napi_value Terminate(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease || lease->closed || !lease->process || !TerminateProcess(lease->process, 127)) { + Throw(env, "PROCESS_TERMINATE"); return nullptr; + } + napi_value result; napi_get_undefined(env, &result); return result; +} + +napi_value Close(napi_env env, napi_callback_info info) { + LaunchLease* lease = LeaseArgument(env, info); + if (!lease) { Throw(env, "LEASE_CLOSED"); return nullptr; } + CloseLease(lease); + napi_value result; napi_get_undefined(env, &result); return result; +} + +bool StringArrayValue(napi_env env, napi_value object, const char* name, size_t expected, + std::vector* result) { + napi_value array; + bool is_array = false; + uint32_t length = 0; + if (napi_get_named_property(env, object, name, &array) != napi_ok + || napi_is_array(env, array, &is_array) != napi_ok || !is_array + || napi_get_array_length(env, array, &length) != napi_ok || length != expected) return false; + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + size_t chars = 0; + if (napi_get_element(env, array, index, &value) != napi_ok + || napi_get_value_string_utf16(env, value, nullptr, 0, &chars) != napi_ok + || chars == 0 || chars > 32767) return false; + std::vector buffer(chars + 1); + if (napi_get_value_string_utf16(env, value, buffer.data(), buffer.size(), &chars) != napi_ok) return false; + result->emplace_back(reinterpret_cast(buffer.data()), chars); + } + return true; +} + +bool Uint32ArrayValue(napi_env env, napi_value object, const char* name, size_t expected, + std::vector* result) { + napi_value array; + bool is_array = false; + uint32_t length = 0; + if (napi_get_named_property(env, object, name, &array) != napi_ok + || napi_is_array(env, array, &is_array) != napi_ok || !is_array + || napi_get_array_length(env, array, &length) != napi_ok || length != expected) return false; + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + uint32_t number = 0; + if (napi_get_element(env, array, index, &value) != napi_ok + || napi_get_value_uint32(env, value, &number) != napi_ok || number == 0 + || number > kMaxBuildInputBytes) return false; + result->push_back(number); + } + return true; +} + +bool Utf8ArrayValue(napi_env env, napi_value object, const char* name, size_t expected, + std::vector* result) { + napi_value array; + bool is_array = false; + uint32_t length = 0; + if (napi_get_named_property(env, object, name, &array) != napi_ok + || napi_is_array(env, array, &is_array) != napi_ok || !is_array + || napi_get_array_length(env, array, &length) != napi_ok || length != expected) return false; + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + size_t bytes = 0; + if (napi_get_element(env, array, index, &value) != napi_ok + || napi_get_value_string_utf8(env, value, nullptr, 0, &bytes) != napi_ok || bytes != 64) return false; + std::vector buffer(bytes + 1); + if (napi_get_value_string_utf8(env, value, buffer.data(), buffer.size(), &bytes) != napi_ok) return false; + result->emplace_back(buffer.data(), bytes); + } + return true; +} + +std::wstring QuoteArgument(const std::wstring& value) { + if (value.find(L'"') != std::wstring::npos || value.find(L'\0') != std::wstring::npos) return {}; + return L"\"" + value + L"\""; +} + +bool SameHeldBuildInput(HANDLE handle, const FileIdInfo& expected_id, DWORD expected_size, + const std::string& expected_hash) { + FileIdInfo after_id{}; + std::string after_hash; + return SecureServicedSystemFile(handle, expected_size, &after_id) && SameIdentity(expected_id, after_id) + && Sha256Handle(handle, expected_size, &after_hash, kMaxBuildInputBytes) && after_hash == expected_hash; +} + +bool SameHeldCatalog(HANDLE handle, const FileIdInfo& expected_id, const std::string& expected_hash) { + LARGE_INTEGER size{}; + FileIdInfo after_id{}; + std::string after_hash; + return handle != INVALID_HANDLE_VALUE && GetFileSizeEx(handle, &size) + && size.QuadPart > 0 && size.QuadPart <= kMaxBuildInputBytes + && SecureServicedSystemFile(handle, static_cast(size.QuadPart), &after_id) + && SameIdentity(expected_id, after_id) + && Sha256Handle(handle, static_cast(size.QuadPart), &after_hash, kMaxBuildInputBytes) + && after_hash == expected_hash; +} + +napi_value CompileHeld(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1], source_value; + std::wstring system_root, output_path, working_directory; + std::vector paths; + std::vector sizes; + std::vector hashes; + std::string fault; + void* source_data = nullptr; + size_t source_size = 0; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "systemRoot", &system_root) + || !StringValue(env, args[0], "output", &output_path) + || !StringValue(env, args[0], "cwd", &working_directory) + || !StringArrayValue(env, args[0], "paths", 3, &paths) + || !Uint32ArrayValue(env, args[0], "sizes", 3, &sizes) + || !Utf8ArrayValue(env, args[0], "sha256", 3, &hashes) + || napi_get_named_property(env, args[0], "source", &source_value) != napi_ok + || napi_get_buffer_info(env, source_value, &source_data, &source_size) != napi_ok + || source_size == 0 || source_size > kMaxSourceBytes) { + Throw(env, "COMPILE_ARGUMENT"); return nullptr; + } + Utf8Value(env, args[0], "fault", &fault, true); + const std::wstring expected_output = working_directory + L"\\propr-windows-authority.exe"; + if (_wcsicmp(output_path.c_str(), expected_output.c_str()) != 0 + || std::any_of(paths.begin(), paths.end(), [](const std::wstring& path) { + return path.find(L'"') != std::wstring::npos || path.find(L'\0') != std::wstring::npos; + })) { + Throw(env, "COMPILE_ARGUMENT"); return nullptr; + } + if (!CanonicalDirectory(system_root, false) || !ProtectPrivateBuildDirectory(working_directory)) { + Throw(env, "DIRECTORY_PROBE"); return nullptr; + } + HANDLE directory_lease = CreateFileW(working_directory.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + FileIdInfo directory_id{}; + if (directory_lease == INVALID_HANDLE_VALUE || !FileIdentity(directory_lease, &directory_id) + || !SecureObjectAcl(directory_lease, true)) { + if (directory_lease != INVALID_HANDLE_VALUE) CloseHandle(directory_lease); + Throw(env, "DIRECTORY_PROBE"); return nullptr; + } + + std::array inputs{INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE}; + std::array catalogs{INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE}; + std::array catalog_contexts{}; + std::array identities{}; + std::array catalog_identities{}; + std::array certificates, spkis, root_spkis, catalog_hashes, catalog_names; + std::array catalog_paths; + CatalogFailure catalog_failure = CatalogFailure::None; + bool inputs_valid = true; + size_t failed_input = inputs.size(); + for (size_t index = 0; index < inputs.size(); ++index) { + inputs[index] = CreateFileW(paths[index].c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (inputs[index] == INVALID_HANDLE_VALUE + || !SecureServicedSystemFile(inputs[index], sizes[index], &identities[index]) + || !Sha256Handle(inputs[index], sizes[index], &certificates[index], kMaxBuildInputBytes) + || certificates[index] != hashes[index]) { + inputs_valid = false; + failed_input = index; + break; + } + } + if (!inputs_valid) { + for (HANDLE handle : inputs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, failed_input == 0 ? "COMPILER_OPEN" : "REFERENCE_OPEN"); return nullptr; + } + for (size_t index = 0; index < inputs.size(); ++index) { + // Overwrite the temporary hash slot with actual signer evidence only after + // exact held-byte authentication. Catalog-signed serviced hard links are + // accepted; reparse points and user-writable aliases are not. + if (!VerifyMicrosoftCompilerInput(paths[index], inputs[index], &certificates[index], &spkis[index], + &root_spkis[index], &catalog_hashes[index], &catalog_names[index], &catalog_paths[index], + &catalog_identities[index], &catalogs[index], &catalog_contexts[index], &catalog_failure)) { + inputs_valid = false; + break; + } + } + if (inputs_valid && fault == "compiler-swapped-catalog") { + // Perform the pathname replacement while the exact catalog is leased. A + // denied mutation and a surprising successful mutation are both a fatal + // test outcome before the compiler process exists. + const bool denied = MutationWasDenied(catalog_paths[0], "swap"); + inputs_valid = false; + catalog_failure = denied ? CatalogFailure::CatalogLease : CatalogFailure::CatalogHash; + } + if (inputs_valid && fault == "compiler-wrong-catalog") { + // Materialize the exact held, valid Microsoft catalog bytes under a + // controlled non-policy identity. This is a real signed catalog attack, + // not a fabricated proof record or a fault label standing in for one. + const std::wstring wrong_path = working_directory + L"\\attacker-wrong-catalog.cat"; + HANDLE wrong_output = CreateFileW(wrong_path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + bool presented = wrong_output != INVALID_HANDLE_VALUE + && SetFilePointer(catalogs[0], 0, nullptr, FILE_BEGIN) != INVALID_SET_FILE_POINTER; + std::array bytes{}; + while (presented) { + DWORD read = 0, written = 0; + if (!ReadFile(catalogs[0], bytes.data(), static_cast(bytes.size()), &read, nullptr)) { + presented = false; break; + } + if (read == 0) break; + if (!WriteFile(wrong_output, bytes.data(), read, &written, nullptr) || written != read) { + presented = false; break; + } + } + if (wrong_output != INVALID_HANDLE_VALUE) { + presented = presented && FlushFileBuffers(wrong_output); + CloseHandle(wrong_output); + } + HANDLE wrong = CreateFileW(wrong_path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + LARGE_INTEGER wrong_size{}; + std::string wrong_hash, wrong_certificate, wrong_spki, wrong_root; + std::wstring wrong_publisher; + presented = presented && wrong != INVALID_HANDLE_VALUE && GetFileSizeEx(wrong, &wrong_size) + && wrong_size.QuadPart > 0 && wrong_size.QuadPart <= kMaxBuildInputBytes + && Sha256Handle(wrong, static_cast(wrong_size.QuadPart), &wrong_hash, kMaxBuildInputBytes) + && SignerEvidence(wrong, SignerContent::StandaloneCatalog, &wrong_publisher, + &wrong_certificate, &wrong_spki, &wrong_root) + && !ApprovedMicrosoftCatalog(paths[0], wrong_path, wrong_certificate, wrong_spki, wrong_hash); + if (wrong != INVALID_HANDLE_VALUE) CloseHandle(wrong); + DeleteFileW(wrong_path.c_str()); + // The copied, genuinely signed bytes reached the same signer parser and + // fixed catalog identity policy. A fixture/setup failure is distinct from + // the expected exact-name/hash rejection and can never be credited as it. + inputs_valid = false; + catalog_failure = presented ? CatalogFailure::CatalogHash : CatalogFailure::SignerParse; + } + if (!inputs_valid) { + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, catalog_failure == CatalogFailure::None + ? "SIGNER_CATALOG" : CatalogFailureCode(catalog_failure)); return nullptr; + } + if ((fault == "compiler-swap-after-open" && !MutationWasDenied(paths[0], "swap")) + || (fault == "reference-swap-after-open" && !MutationWasDenied(paths[1], "swap"))) { + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, "LEASE"); return nullptr; + } + + std::array random{}; + if (BCryptGenRandom(nullptr, random.data(), static_cast(random.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, "SOURCE_COPY"); return nullptr; + } + const std::string random_hex = Hex(random.data(), random.size()); + const std::wstring random_name(random_hex.begin(), random_hex.end()); + const std::wstring source_path = working_directory + L"\\source-" + random_name + L".cs"; + HANDLE source = CreateFileW(source_path.c_str(), GENERIC_READ | GENERIC_WRITE | READ_CONTROL, FILE_SHARE_READ, + nullptr, CREATE_NEW, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + DWORD written = 0; + bool source_valid = source != INVALID_HANDLE_VALUE + && WriteFile(source, source_data, static_cast(source_size), &written, nullptr) && written == source_size + && FlushFileBuffers(source) && SetFilePointer(source, 0, nullptr, FILE_BEGIN) != INVALID_SET_FILE_POINTER; + FileIdInfo source_id{}; + std::string source_hash; + source_valid = source_valid && SecureRegularFile(source, static_cast(source_size), &source_id, false) + && Sha256Handle(source, static_cast(source_size), &source_hash); + if (!source_valid) { + if (source != INVALID_HANDLE_VALUE) CloseHandle(source); + DeleteFileW(source_path.c_str()); + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + CloseHandle(directory_lease); + Throw(env, "SOURCE_COPY"); return nullptr; + } + if ((fault == "source-swap-after-copy" || fault == "source-rename" || fault == "source-reparse" + || fault == "source-replace") && !MutationWasDenied(source_path, "swap")) source_valid = false; + if (fault == "source-truncate" && !MutationWasDenied(source_path, "write")) source_valid = false; + if (fault == "source-hardlink") { + const std::wstring extra_link = source_path + L".link"; + CreateHardLinkW(extra_link.c_str(), source_path.c_str(), nullptr); + DeleteFileW(extra_link.c_str()); + } + if ((fault == "compiler-swap-before-create" && !MutationWasDenied(paths[0], "swap")) + || (fault == "reference-swap-before-create" && !MutationWasDenied(paths[1], "swap"))) source_valid = false; + + SECURITY_ATTRIBUTES inheritable{sizeof(inheritable), nullptr, TRUE}; + HANDLE child_stdin = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + HANDLE child_stdout = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + HANDLE child_stderr = CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + HANDLE inherited[] = {child_stdin, child_stdout, child_stderr}; + SIZE_T attribute_bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_bytes); + std::vector attribute_storage(attribute_bytes); + auto* attributes = reinterpret_cast(attribute_storage.data()); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = child_stdin; + startup.StartupInfo.hStdOutput = child_stdout; + startup.StartupInfo.hStdError = child_stderr; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION process{}; + const std::wstring compiler_arg = QuoteArgument(paths[0]); + const std::wstring output_arg = QuoteArgument(L"/out:" + output_path); + const std::wstring reference_one = QuoteArgument(L"/reference:" + paths[1]); + const std::wstring reference_two = QuoteArgument(L"/reference:" + paths[2]); + const std::wstring source_arg = QuoteArgument(source_path); + std::wstring command = compiler_arg + L" /nologo /noconfig /target:exe /platform:anycpu /optimize+ /checked+" + L" /warnaserror+ " + output_arg + L" " + reference_one + L" " + reference_two + L" " + source_arg; + std::wstring environment = L"SystemRoot=" + system_root + L'\0' + L'\0'; + const bool attributes_initialized = child_stdin != INVALID_HANDLE_VALUE && child_stdout != INVALID_HANDLE_VALUE + && child_stderr != INVALID_HANDLE_VALUE && source_valid + && InitializeProcThreadAttributeList(attributes, 1, 0, &attribute_bytes); + bool created = attributes_initialized + && UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) + && CreateProcessW(paths[0].c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + environment.data(), working_directory.c_str(), &startup.StartupInfo, &process); + if (attributes_initialized) DeleteProcThreadAttributeList(attributes); + if (child_stdin != INVALID_HANDLE_VALUE) CloseHandle(child_stdin); + if (child_stdout != INVALID_HANDLE_VALUE) CloseHandle(child_stdout); + if (child_stderr != INVALID_HANDLE_VALUE) CloseHandle(child_stderr); + + HANDLE job = created ? CreateJobObjectW(nullptr, nullptr) : nullptr; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + limits.BasicLimitInformation.ActiveProcessLimit = 1; + bool image_proven = created && job && SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) + && AssignProcessToJobObject(job, process.hProcess) && fault != "compiler-job"; + std::array loaded_path{}; + DWORD loaded_length = static_cast(loaded_path.size()); + image_proven = image_proven && QueryFullProcessImageNameW(process.hProcess, 0, loaded_path.data(), &loaded_length); + HANDLE loaded = image_proven ? CreateFileW(loaded_path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) : INVALID_HANDLE_VALUE; + FileIdInfo loaded_id{}; + std::string loaded_hash; + image_proven = image_proven && loaded != INVALID_HANDLE_VALUE + && SecureServicedSystemFile(loaded, sizes[0], &loaded_id) && SameIdentity(identities[0], loaded_id) + && Sha256Handle(loaded, sizes[0], &loaded_hash, kMaxBuildInputBytes) && loaded_hash == hashes[0] + && fault != "compiler-image"; + if (fault == "compiler-swap-after-process" && !MutationWasDenied(paths[0], "swap")) image_proven = false; + if (loaded != INVALID_HANDLE_VALUE) CloseHandle(loaded); + bool exited = image_proven && ResumeThread(process.hThread) != static_cast(-1) + && WaitForSingleObject(process.hProcess, 60'000) == WAIT_OBJECT_0; + DWORD exit_code = 1; + if (exited) exited = GetExitCodeProcess(process.hProcess, &exit_code) && exit_code == 0 && fault != "compiler-exit"; + if (created && (!exited || !image_proven)) { + TerminateProcess(process.hProcess, 127); + WaitForSingleObject(process.hProcess, 5'000); + } + if (created) { CloseHandle(process.hThread); CloseHandle(process.hProcess); } + + bool lease_proven = image_proven && exited; + FileIdInfo directory_after{}; + lease_proven = lease_proven && FileIdentity(directory_lease, &directory_after) + && SameIdentity(directory_id, directory_after) && SecureObjectAcl(directory_lease, true); + for (size_t index = 0; index < inputs.size(); ++index) { + lease_proven = lease_proven && SameHeldBuildInput(inputs[index], identities[index], sizes[index], hashes[index]); + lease_proven = lease_proven + && SameHeldCatalog(catalogs[index], catalog_identities[index], catalog_hashes[index]); + } + FileIdInfo source_after{}; + std::string source_after_hash; + lease_proven = lease_proven && SecureRegularFile(source, static_cast(source_size), &source_after, false) + && SameIdentity(source_id, source_after) + && Sha256Handle(source, static_cast(source_size), &source_after_hash) && source_after_hash == source_hash; + CloseHandle(source); + DeleteFileW(source_path.c_str()); + for (HANDLE handle : inputs) CloseHandle(handle); + for (HANDLE handle : catalogs) CloseHandle(handle); + + HANDLE output = lease_proven ? CreateFileW(output_path.c_str(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr) : INVALID_HANDLE_VALUE; + LARGE_INTEGER output_size{}; + FileIdInfo output_id{}; + std::string output_hash; + bool output_valid = output != INVALID_HANDLE_VALUE && GetFileSizeEx(output, &output_size) + && output_size.QuadPart > 0 && output_size.QuadPart <= kMaxImageBytes + && SecureRegularFile(output, static_cast(output_size.QuadPart), &output_id, false) + && Sha256Handle(output, static_cast(output_size.QuadPart), &output_hash) + && fault != "compiler-output"; + if (output != INVALID_HANDLE_VALUE) CloseHandle(output); + if (job) CloseHandle(job); + CloseHandle(directory_lease); + if (!created) { Throw(env, "SPAWN"); return nullptr; } + if (!image_proven) { Throw(env, "IMAGE"); return nullptr; } + if (!exited) { Throw(env, "EXIT"); return nullptr; } + if (!lease_proven) { Throw(env, "LEASE"); return nullptr; } + if (!output_valid) { Throw(env, "OUTPUT_VALIDATION"); return nullptr; } + + napi_value result, value; + napi_create_object(env, &result); + napi_create_uint32(env, static_cast(output_size.QuadPart), &value); + napi_set_named_property(env, result, "size", value); + napi_create_string_utf8(env, output_hash.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "sha256", value); + napi_create_string_utf8(env, certificates[0].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerCertificateSha256", value); + napi_create_string_utf8(env, spkis[0].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerSpkiSha256", value); + napi_create_string_utf8(env, root_spkis[0].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerRootSpkiSha256", value); + napi_value certificate_values, spki_values, root_values, catalog_name_values, catalog_values, + catalog_volume_values, catalog_id_values; + napi_create_array_with_length(env, inputs.size(), &certificate_values); + napi_create_array_with_length(env, inputs.size(), &spki_values); + napi_create_array_with_length(env, inputs.size(), &root_values); + napi_create_array_with_length(env, inputs.size(), &catalog_name_values); + napi_create_array_with_length(env, inputs.size(), &catalog_values); + napi_create_array_with_length(env, inputs.size(), &catalog_volume_values); + napi_create_array_with_length(env, inputs.size(), &catalog_id_values); + for (uint32_t index = 0; index < inputs.size(); ++index) { + napi_create_string_utf8(env, certificates[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, certificate_values, index, value); + napi_create_string_utf8(env, spkis[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, spki_values, index, value); + napi_create_string_utf8(env, root_spkis[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, root_values, index, value); + napi_create_string_utf8(env, catalog_names[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_name_values, index, value); + napi_create_string_utf8(env, catalog_hashes[index].c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_values, index, value); + char catalog_volume[17]{}; + sprintf_s(catalog_volume, "%016llx", catalog_identities[index].volume); + napi_create_string_utf8(env, catalog_volume, NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_volume_values, index, value); + const std::string catalog_file_id = Hex(catalog_identities[index].id, sizeof(catalog_identities[index].id)); + napi_create_string_utf8(env, catalog_file_id.c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_element(env, catalog_id_values, index, value); + } + napi_set_named_property(env, result, "inputCertificateSha256", certificate_values); + napi_set_named_property(env, result, "inputSpkiSha256", spki_values); + napi_set_named_property(env, result, "inputRootSpkiSha256", root_values); + napi_set_named_property(env, result, "inputCatalogName", catalog_name_values); + napi_set_named_property(env, result, "inputCatalogSha256", catalog_values); + napi_set_named_property(env, result, "inputCatalogVolumeSerial", catalog_volume_values); + napi_set_named_property(env, result, "inputCatalogFileId128", catalog_id_values); + char volume[17]{}; + sprintf_s(volume, "%016llx", identities[0].volume); + napi_create_string_utf8(env, volume, NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerVolumeSerial", value); + napi_create_string_utf8(env, Hex(identities[0].id, sizeof(identities[0].id)).c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "compilerFileId128", value); + return result; +} + +napi_value LeaseFiles(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + bool array = false; + uint32_t length = 0; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || napi_is_array(env, args[0], &array) != napi_ok || !array + || napi_get_array_length(env, args[0], &length) != napi_ok || length != 4) { + Throw(env, "LEASE_ARGUMENT"); return nullptr; + } + auto* leases = new FileLeases(); + for (uint32_t index = 0; index < length; ++index) { + napi_value value; + size_t chars = 0; + if (napi_get_element(env, args[0], index, &value) != napi_ok + || napi_get_value_string_utf16(env, value, nullptr, 0, &chars) != napi_ok || chars == 0 || chars > 32767) { + CloseFileLeases(leases); delete leases; Throw(env, "LEASE_ARGUMENT"); return nullptr; + } + std::vector buffer(chars + 1); + napi_get_value_string_utf16(env, value, buffer.data(), buffer.size(), &chars); + const bool directory_expected = index == 0; + HANDLE file = CreateFileW(reinterpret_cast(buffer.data()), + (directory_expected ? FILE_READ_ATTRIBUTES : GENERIC_READ) | READ_CONTROL, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | (directory_expected ? FILE_FLAG_BACKUP_SEMANTICS : FILE_FLAG_SEQUENTIAL_SCAN), + nullptr); + LARGE_INTEGER size{}; + FileIdInfo identity{}; + AttributeTagInfo tag{}; + const bool directory_valid = directory_expected && file != INVALID_HANDLE_VALUE + && GetFileInformationByHandleEx(file, static_cast(kFileAttributeTagInfo), + &tag, sizeof(tag)) && FileIdentity(file, &identity) + && (tag.attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 + && (tag.attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && tag.reparse_tag == 0 && SecureObjectAcl(file); + const bool file_valid = !directory_expected && file != INVALID_HANDLE_VALUE + && GetFileSizeEx(file, &size) && size.QuadPart > 0 && size.QuadPart <= 32ll * 1024 * 1024 + && SecureRegularFile(file, static_cast(size.QuadPart), &identity, false); + if (!directory_valid && !file_valid) { + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + CloseFileLeases(leases); delete leases; Throw(env, "LEASE_AUTHORITY"); return nullptr; + } + leases->handles.push_back(file); + } + napi_value result; + napi_create_external(env, leases, FinalizeFileLeases, nullptr, &result); + return result; +} + +napi_value CloseFileLease(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + void* data = nullptr; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || napi_get_value_external(env, args[0], &data) != napi_ok) { + Throw(env, "LEASE_ARGUMENT"); return nullptr; + } + CloseFileLeases(static_cast(data)); + napi_value result; napi_get_undefined(env, &result); return result; +} + +napi_value DangerousAclForTest(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring sddl; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "sddl", &sddl) || sddl.size() > 4096) { + Throw(env, "ACL_TEST_ARGUMENT"); return nullptr; + } + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + BOOL present = FALSE, defaulted = FALSE; + const bool parsed = ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.c_str(), SDDL_REVISION_1, + &descriptor, nullptr) && GetSecurityDescriptorDacl(descriptor, &present, &dacl, &defaulted) && present && dacl; + if (!parsed) { + if (descriptor) LocalFree(descriptor); + Throw(env, "ACL_TEST_PARSE"); return nullptr; + } + const bool dangerous = DangerousUntrustedAcl(dacl, false); + LocalFree(descriptor); + napi_value result; + napi_get_boolean(env, dangerous, &result); + return result; +} + +napi_value VerifyModule(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + std::wstring expected_path; + std::string expected_hash; + std::string publisher, certificate_pin, spki_pin; + uint32_t expected_size = 0; + bool production = false; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || argc != 1 + || !StringValue(env, args[0], "path", &expected_path) + || !Utf8Value(env, args[0], "sha256", &expected_hash) + || !Uint32Value(env, args[0], "size", &expected_size) + || !BoolValue(env, args[0], "production", &production)) { + Throw(env, "MODULE_ARGUMENT"); return nullptr; + } + Utf8Value(env, args[0], "publisher", &publisher, true); + Utf8Value(env, args[0], "signerCertificateSha256", &certificate_pin, true); + Utf8Value(env, args[0], "signerSpkiSha256", &spki_pin, true); + HMODULE module = nullptr; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&VerifyModule), &module)) { Throw(env, "MODULE_IMAGE"); return nullptr; } + std::array path{}; + const DWORD length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size() || _wcsicmp(path.data(), expected_path.c_str()) != 0) { + Throw(env, "MODULE_IMAGE"); return nullptr; + } + HANDLE file = CreateFileW(path.data(), GENERIC_READ | READ_CONTROL, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + FileIdInfo identity{}; + std::string hash; + const bool valid = file != INVALID_HANDLE_VALUE && SecureRegularFile(file, expected_size, &identity, false) + && ExpectedArchitecture(file) && Sha256Handle(file, expected_size, &hash) && hash == expected_hash + && (!production || VerifyPinnedSignature(path.data(), file, publisher, certificate_pin, spki_pin)); + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + if (!valid) { Throw(env, "MODULE_AUTHORITY"); return nullptr; } + napi_value result, value; + napi_create_object(env, &result); + napi_create_string_utf8(env, hash.c_str(), NAPI_AUTO_LENGTH, &value); napi_set_named_property(env, result, "sha256", value); +#if defined(_M_ARM64) + napi_create_string_utf8(env, "arm64", NAPI_AUTO_LENGTH, &value); +#else + napi_create_string_utf8(env, "x64", NAPI_AUTO_LENGTH, &value); +#endif + napi_set_named_property(env, result, "architecture", value); + napi_create_string_utf8(env, Hex(identity.id, sizeof(identity.id)).c_str(), NAPI_AUTO_LENGTH, &value); + napi_set_named_property(env, result, "fileId128", value); + return result; +} + +napi_value Init(napi_env env, napi_value exports) { +#if defined(PROPR_WINDOWS_MALICIOUS_BOOTSTRAP) + std::array side_effect{}; + const DWORD side_effect_length = GetEnvironmentVariableW(L"PROPR_WINDOWS_MALICIOUS_BOOTSTRAP_SIDE_EFFECT", + side_effect.data(), static_cast(side_effect.size())); + if (side_effect_length > 0 && side_effect_length < side_effect.size()) { + HANDLE marker = CreateFileW(side_effect.data(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (marker != INVALID_HANDLE_VALUE) CloseHandle(marker); + } +#endif +#if defined(PROPR_WINDOWS_BOOTSTRAP_ONLY) + napi_property_descriptor properties[] = { + {"loadVerifiedModule", nullptr, LoadVerifiedModule, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; +#else + napi_property_descriptor properties[] = { + {"probeSystemDirectory", nullptr, ProbeSystemDirectory, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"launch", nullptr, Launch, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"status", nullptr, Status, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"closeInput", nullptr, CloseInput, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"terminate", nullptr, Terminate, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"close", nullptr, Close, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"compileHeld", nullptr, CompileHeld, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"leaseFiles", nullptr, LeaseFiles, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"closeFileLease", nullptr, CloseFileLease, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"dangerousAclForTest", nullptr, DangerousAclForTest, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; +#endif + napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties); + return exports; +} +} // namespace + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/apps/desktop/src/release-config.test.ts b/apps/desktop/src/release-config.test.ts new file mode 100644 index 000000000..5662b8c32 --- /dev/null +++ b/apps/desktop/src/release-config.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { describe, test } from 'node:test'; +import { + readCompleteEnvironmentGroup, + parseWindowsSignerPins, + requireProductionReleaseConfiguration, + resolveDesktopVersion, + resolveTrustedUpdateBuildConfig, +} from './release-config'; +import { squirrelAppUserModelId } from './squirrel-events'; + +const publicKey = generateKeyPairSync('ed25519').publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); +const certificatePin = `certificate-sha256:${'1'.repeat(64)}`; +const spkiPin = `spki-sha256:${'2'.repeat(64)}`; + +interface LinuxMaker { + name: 'deb' | 'rpm'; + config: { options?: { bin?: string } }; + prepareConfig: (targetArch: 'x64') => Promise; +} + +const isLinuxMaker = (maker: unknown): maker is LinuxMaker => { + if (typeof maker !== 'object' || maker === null || !('name' in maker)) return false; + return maker.name === 'deb' || maker.name === 'rpm'; +}; + +describe('desktop release configuration', () => { + test('keeps Linux maker executables aligned with the packaged executable', async () => { + const previousDeb = process.env.PROPR_DESKTOP_ENABLE_DEB; + const previousRpm = process.env.PROPR_DESKTOP_ENABLE_RPM; + process.env.PROPR_DESKTOP_ENABLE_DEB = '1'; + process.env.PROPR_DESKTOP_ENABLE_RPM = '1'; + try { + const { default: forgeConfig } = await import('../forge.config'); + const executableName = forgeConfig.packagerConfig?.executableName; + assert.equal(executableName, 'propr-desktop'); + assert.equal(squirrelAppUserModelId(executableName), 'com.squirrel.propr_desktop.propr-desktop'); + + const linuxMakers = forgeConfig.makers?.filter(isLinuxMaker) ?? []; + assert.deepEqual(linuxMakers.map(maker => maker.name).sort(), ['deb', 'rpm']); + for (const maker of linuxMakers) { + await maker.prepareConfig('x64'); + assert.equal(maker.config.options?.bin, executableName); + assert.notEqual(maker.config.options?.bin, '@propr/desktop'); + } + } finally { + if (previousDeb === undefined) delete process.env.PROPR_DESKTOP_ENABLE_DEB; + else process.env.PROPR_DESKTOP_ENABLE_DEB = previousDeb; + if (previousRpm === undefined) delete process.env.PROPR_DESKTOP_ENABLE_RPM; + else process.env.PROPR_DESKTOP_ENABLE_RPM = previousRpm; + } + }); + + test('propagates an explicit independent desktop version', () => { + assert.equal(resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: '2.3.4' }), '2.3.4'); + assert.throws(() => resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: 'v2.3.4' }), /stable semver/); + assert.throws(() => resolveDesktopVersion('0.8.15', { PROPR_DESKTOP_VERSION: '2.3.4-beta.1' }), /stable semver/); + }); + + test('keeps updates disabled unless they are explicitly enabled', () => { + assert.deepEqual(resolveTrustedUpdateBuildConfig({}), { + enabled: false, + manifestUrl: '', + publicKey: '', + signingIdentity: '', + windowsSignerPins: [], + }); + }); + + test('requires a signed build and a complete trusted update configuration', () => { + const base = { + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'Example Publisher', + }; + assert.throws(() => resolveTrustedUpdateBuildConfig(base), /CODE_SIGNED/); + assert.deepEqual(resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_CODE_SIGNED: '1' }, 'darwin'), { + enabled: true, + manifestUrl: 'https://updates.example.test/stable/desktop-release.json', + publicKey, + signingIdentity: 'Example Publisher', + windowsSignerPins: [], + }); + assert.throws( + () => resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_CODE_SIGNED: '1', PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'http://example.test/update.json' }), + /HTTPS/, + ); + assert.throws( + () => resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_CODE_SIGNED: '1', PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://example.test/update.json?channel=stable' }), + /query/, + ); + }); + + test('requires a canonical Windows certificate or SPKI SHA-256 pin allowlist', () => { + assert.deepEqual(parseWindowsSignerPins(`${certificatePin},${spkiPin}`), [certificatePin, spkiPin]); + for (const value of [ + undefined, + '', + `certificate-sha256:${'A'.repeat(64)}`, + `certificate-sha256:${'1'.repeat(63)}`, + `${spkiPin},${certificatePin}`, + `${certificatePin},${certificatePin}`, + ` ${certificatePin}`, + `sha256:${'1'.repeat(64)}`, + ]) assert.throws(() => parseWindowsSignerPins(value), /required|sorted, unique/); + + const base = { + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_CODE_SIGNED: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'CN=Example Publisher', + }; + assert.throws(() => resolveTrustedUpdateBuildConfig(base, 'win32'), /WINDOWS_SIGNER_PINS is required/); + assert.deepEqual( + resolveTrustedUpdateBuildConfig({ ...base, PROPR_DESKTOP_WINDOWS_SIGNER_PINS: certificatePin }, 'win32').windowsSignerPins, + [certificatePin], + ); + }); + + test('rejects partially configured signing groups', () => { + assert.equal(readCompleteEnvironmentGroup({}, ['CERT', 'PASSWORD'], 'Windows signing'), undefined); + assert.throws( + () => readCompleteEnvironmentGroup({ CERT: '/tmp/cert.pfx' }, ['CERT', 'PASSWORD'], 'Windows signing'), + /missing PASSWORD/, + ); + }); + + test('fails closed when a production signing or notarization condition is absent', () => { + const enabledUpdates = resolveTrustedUpdateBuildConfig({ + PROPR_DESKTOP_ENABLE_UPDATES: '1', + PROPR_DESKTOP_CODE_SIGNED: '1', + PROPR_DESKTOP_UPDATE_MANIFEST_URL: 'https://updates.example.test/stable/desktop-release.json', + PROPR_DESKTOP_UPDATE_PUBLIC_KEY: publicKey, + PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY: 'TEAM123456', + }, 'darwin'); + const enabledWindowsUpdates = { + ...enabledUpdates, + windowsSignerPins: [certificatePin], + }; + const group = { configured: 'yes' }; + assert.throws( + () => requireProductionReleaseConfiguration({ platform: 'darwin', updateConfig: enabledUpdates, macSigning: group }), + /notarization/, + ); + assert.throws( + () => requireProductionReleaseConfiguration({ platform: 'darwin', updateConfig: { enabled: false, manifestUrl: '', publicKey: '', signingIdentity: '', windowsSignerPins: [] }, macSigning: group, macNotarization: group }), + /signed updates/, + ); + assert.throws( + () => requireProductionReleaseConfiguration({ platform: 'win32', updateConfig: enabledWindowsUpdates }), + /Authenticode/, + ); + assert.doesNotThrow( + () => requireProductionReleaseConfiguration({ platform: 'darwin', updateConfig: enabledUpdates, macSigning: group, macNotarization: group }), + ); + assert.doesNotThrow( + () => requireProductionReleaseConfiguration({ platform: 'win32', updateConfig: enabledWindowsUpdates, windowsSigning: group }), + ); + }); +}); diff --git a/apps/desktop/src/release-config.ts b/apps/desktop/src/release-config.ts new file mode 100644 index 000000000..c47c0303d --- /dev/null +++ b/apps/desktop/src/release-config.ts @@ -0,0 +1,136 @@ +import { createPublicKey } from 'node:crypto'; + +export type Environment = Readonly>; + +export interface TrustedUpdateBuildConfig { + enabled: boolean; + manifestUrl: string; + publicKey: string; + signingIdentity: string; + windowsSignerPins: readonly string[]; +} + +const RELEASE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const WINDOWS_SIGNER_PIN_PATTERN = /^(?:certificate|spki)-sha256:[a-f0-9]{64}$/; +const MAX_WINDOWS_SIGNER_PINS = 16; + +export const parseWindowsSignerPins = ( + value: string | undefined, + label = 'PROPR_DESKTOP_WINDOWS_SIGNER_PINS', +): readonly string[] => { + if (!value) throw new Error(`${label} is required`); + const pins = value.split(','); + if (pins.length > MAX_WINDOWS_SIGNER_PINS + || pins.some(pin => !WINDOWS_SIGNER_PIN_PATTERN.test(pin)) + || new Set(pins).size !== pins.length + || pins.join(',') !== [...pins].sort().join(',')) { + throw new Error( + `${label} must be a sorted, unique comma-separated allowlist of canonical certificate-sha256 or spki-sha256 fingerprints`, + ); + } + return pins; +}; + +export const resolveDesktopVersion = (packageVersion: string, env: Environment = process.env): string => { + const version = env.PROPR_DESKTOP_VERSION?.trim() || packageVersion; + if (!RELEASE_VERSION_PATTERN.test(version)) { + throw new Error(`ProPR Desktop version must be canonical stable semver (received ${JSON.stringify(version)})`); + } + return version; +}; + +const validateHttpsUrl = (value: string, label: string): string => { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be an absolute HTTPS URL`); + } + if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.search) { + throw new Error(`${label} must be an HTTPS URL without credentials, a fragment, or a query`); + } + return url.toString(); +}; + +const validateEd25519PublicKey = (value: string): string => { + try { + const key = createPublicKey({ key: Buffer.from(value, 'base64'), format: 'der', type: 'spki' }); + if (key.asymmetricKeyType !== 'ed25519') throw new Error('wrong key type'); + } catch { + throw new Error('PROPR_DESKTOP_UPDATE_PUBLIC_KEY must be a base64-encoded Ed25519 SPKI DER public key'); + } + return value; +}; + +export const resolveTrustedUpdateBuildConfig = ( + env: Environment = process.env, + platform: NodeJS.Platform = process.platform, +): TrustedUpdateBuildConfig => { + if (env.PROPR_DESKTOP_ENABLE_UPDATES !== '1') { + return { enabled: false, manifestUrl: '', publicKey: '', signingIdentity: '', windowsSignerPins: [] }; + } + if (env.PROPR_DESKTOP_CODE_SIGNED !== '1') { + throw new Error('Signed updates require PROPR_DESKTOP_CODE_SIGNED=1 from the trusted signing job'); + } + + const manifestUrl = env.PROPR_DESKTOP_UPDATE_MANIFEST_URL?.trim(); + const publicKey = env.PROPR_DESKTOP_UPDATE_PUBLIC_KEY?.trim(); + const signingIdentity = env.PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY?.trim(); + if (!manifestUrl || !publicKey || !signingIdentity) { + throw new Error( + 'Signed updates require PROPR_DESKTOP_UPDATE_MANIFEST_URL, PROPR_DESKTOP_UPDATE_PUBLIC_KEY, and PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY', + ); + } + + return { + enabled: true, + manifestUrl: validateHttpsUrl(manifestUrl, 'PROPR_DESKTOP_UPDATE_MANIFEST_URL'), + publicKey: validateEd25519PublicKey(publicKey), + signingIdentity, + windowsSignerPins: platform === 'win32' + ? parseWindowsSignerPins(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS) + : [], + }; +}; + +interface CompleteEnvironmentGroup { + [name: string]: string; +} + +export const readCompleteEnvironmentGroup = ( + env: Environment, + names: readonly string[], + label: string, +): CompleteEnvironmentGroup | undefined => { + const present = names.filter(name => Boolean(env[name]?.trim())); + if (present.length === 0) return undefined; + if (present.length !== names.length) { + const missing = names.filter(name => !env[name]?.trim()); + throw new Error(`${label} configuration is incomplete; missing ${missing.join(', ')}`); + } + return Object.fromEntries(names.map(name => [name, env[name]!.trim()])); +}; + +export const requireProductionReleaseConfiguration = ({ + platform, + updateConfig, + macSigning, + macNotarization, + windowsSigning, +}: { + platform: NodeJS.Platform; + updateConfig: TrustedUpdateBuildConfig; + macSigning?: CompleteEnvironmentGroup; + macNotarization?: CompleteEnvironmentGroup; + windowsSigning?: CompleteEnvironmentGroup; +}): void => { + if (platform === 'darwin' && (!macSigning || !macNotarization || !updateConfig.enabled)) { + throw new Error('Production macOS releases require signing, notarization, and signed updates'); + } + if (platform === 'win32' && (!windowsSigning || !updateConfig.enabled)) { + throw new Error('Production Windows releases require Authenticode signing and signed updates'); + } + if (platform === 'win32' && updateConfig.windowsSignerPins.length === 0) { + throw new Error('Production Windows releases require an Authenticode certificate or SPKI SHA-256 signer pin'); + } +}; diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts new file mode 100644 index 000000000..1ba9dba9a --- /dev/null +++ b/apps/desktop/src/release-workflow.test.ts @@ -0,0 +1,408 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; + +const normalizeWorkflowText = (contents: string): string => contents.replace(/\r\n?/g, '\n'); +const platformArchitecturePattern = /platform: (linux|darwin|win32)\n\s+arch: (x64|arm64)/g; +const workflow = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../../../.github/workflows/desktop-release-guard.yml', import.meta.url)), + 'utf8', +)); +const releaseArchitecture = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/release-architecture.mjs', import.meta.url)), + 'utf8', +)); +const releaseArtifacts = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/release-artifacts.mjs', import.meta.url)), + 'utf8', +)); +const makeDmg = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/make-dmg.mjs', import.meta.url)), + 'utf8', +)); +const verifyDarwinImage = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/verify-darwin-image.mjs', import.meta.url)), + 'utf8', +)); +const releasePreflight = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/release-preflight.mjs', import.meta.url)), + 'utf8', +)); +const windowsAuthority = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('./windows-update-authority.ts', import.meta.url)), + 'utf8', +)); +const windowsAuthoritySource = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('./native/propr-windows-authority.cs', import.meta.url)), + 'utf8', +)); +const windowsAuthorityBuild = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/build-windows-authority-helper.mjs', import.meta.url)), + 'utf8', +)); +const windowsNativeLauncher = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('./native/windows-launcher/propr_windows_launcher.cc', import.meta.url)), + 'utf8', +)); +const forgeConfig = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../forge.config.ts', import.meta.url)), + 'utf8', +)); +const windowsMachineInstaller = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/build-windows-machine-installer.mjs', import.meta.url)), + 'utf8', +)); +const installedWindowsAuthorityTest = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-authority.ps1', import.meta.url)), + 'utf8', +)); + +const preflightAppTokenPermissions = (preflight: string): string[] => ( + [...preflight.matchAll(/^\s+permission-([a-z-]+): (read|write)$/gm)] + .map(match => `${match[1]}:${match[2]}`) +); + +const environmentApiPermissionFixtures = [ + { + endpoint: 'GET /repos/{owner}/{repo}/environments/{environment_name}', + sources: [/request\(`\/environments\/\$\{environmentName\}`\)/], + permission: 'actions:read', + }, + { + endpoint: 'GET /repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies', + sources: [ + /`\/environments\/\$\{environmentName\}\/deployment-branch-policies`/, + /paginatedDeploymentPolicies\(request, environmentName\)/, + ], + permission: 'actions:read', + }, +] as const; + +const job = (name: string, next?: string): string => { + const start = workflow.indexOf(`\n ${name}:`); + const end = next ? workflow.indexOf(`\n ${next}:`, start + 1) : workflow.length; + assert.notEqual(start, -1, `missing ${name} job`); + assert.notEqual(end, -1, `missing ${next} job`); + return workflow.slice(start, end); +}; + +describe('desktop trusted release workflow', () => { + test('keeps pull-request packaging unsigned and completely secretless', () => { + const validation = `${job('validation-version', 'package')}\n${job('package', 'finalize')}\n${job('finalize', 'preflight')}`; + assert.match(validation, /github\.event_name == 'pull_request'/); + assert.match(validation, /Prove pull-request validation is secretless/); + assert.ok(!validation.includes('secrets.'), 'PR jobs must not reference any GitHub secret'); + assert.ok(!validation.includes('PROPR_DESKTOP_UPDATE_PRIVATE_KEY')); + assert.ok(!validation.includes('environment:\n')); + assert.ok(!validation.includes('PROPR_DESKTOP_ENABLE_UPDATES=1')); + }); + + test('allows production only from a new protected-main desktop tag after protected read-only preflight', () => { + const preflight = job('preflight', 'release-package'); + const production = job('release-package', 'release-finalize'); + assert.ok(!workflow.includes('workflow_dispatch:')); + assert.match(preflight, /github\.event_name == 'push'/); + assert.match(preflight, /release-preflight\.mjs/); + assert.match(preflight, /ref: \$\{\{ github\.sha \}\}/); + assert.match(preflight, /environment:\s+name: desktop-release-preflight/); + assert.match(preflight, /actions\/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1/); + assert.match(preflight, /app-id: \$\{\{ vars\.PROPR_DESKTOP_PREFLIGHT_APP_ID \}\}/); + assert.match(preflight, /private-key: \$\{\{ secrets\.PROPR_DESKTOP_PREFLIGHT_APP_PRIVATE_KEY \}\}/); + assert.match(preflight, /permission-actions: read/); + assert.match(preflight, /permission-administration: read/); + assert.match(preflight, /permission-contents: read/); + assert.deepEqual( + preflightAppTokenPermissions(preflight), + ['actions:read', 'administration:read', 'contents:read'], + ); + assert.match(preflight, /GITHUB_TOKEN: \$\{\{ steps\.preflight-app-token\.outputs\.token \}\}/); + assert.equal(workflow.match(/steps\.preflight-app-token\.outputs\.token/g)?.length, 1); + assert.equal(preflight.match(/secrets\./g)?.length, 1); + assert.ok(!preflight.includes('PROPR_DESKTOP_UPDATE_PRIVATE_KEY')); + assert.ok(!preflight.includes('PROPR_DESKTOP_MAC_CERTIFICATE')); + assert.ok(!preflight.includes('PROPR_DESKTOP_WINDOWS_CERTIFICATE')); + assert.ok(!preflight.includes('permission-actions: write')); + assert.ok(!preflight.includes('permission-administration: write')); + assert.ok(!preflight.includes('permission-contents: write')); + assert.match(production, /needs: preflight/); + assert.match(production, /environment:\s+name: desktop-release/); + assert.match(production, /ref: \$\{\{ needs\.preflight\.outputs\.release_sha \}\}/); + assert.match(production, /gh api .*commits\/\$RELEASE_TAG/); + assert.match(production, /! gh release view/); + }); + + test('grants the preflight token Actions read for both environment API calls without exposing it', () => { + const preflight = job('preflight', 'release-package'); + const permissions = preflightAppTokenPermissions(preflight); + for (const fixture of environmentApiPermissionFixtures) { + for (const source of fixture.sources) { + assert.match(releasePreflight, source, `missing ${fixture.endpoint}`); + } + assert.ok(permissions.includes(fixture.permission), `${fixture.endpoint} requires ${fixture.permission}`); + } + assert.deepEqual(permissions, ['actions:read', 'administration:read', 'contents:read']); + assert.match(preflight, /persist-credentials: false/); + assert.equal(preflight.match(/steps\.preflight-app-token\.outputs\.token/g)?.length, 1); + assert.ok(!/^\s+token:\s+\$\{\{ steps\.preflight-app-token\.outputs\.token \}\}/m.test(preflight)); + assert.ok(!preflight.includes('permission-environments:')); + }); + + test('keeps every certificate and the update private key inside preflight-dependent environment jobs', () => { + const packageJob = job('release-package', 'release-finalize'); + const signing = job('sign', 'publish'); + for (const secret of [ + 'PROPR_DESKTOP_MAC_CERTIFICATE_P12_BASE64', + 'PROPR_DESKTOP_MAC_CERTIFICATE_PASSWORD', + 'PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64', + 'PROPR_DESKTOP_APPLE_API_KEY_ID', + 'PROPR_DESKTOP_APPLE_API_ISSUER_ID', + 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64', + 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD', + ]) { + assert.equal(workflow.match(new RegExp(`secrets\\.${secret}`, 'g'))?.length, 1); + assert.ok(packageJob.includes(`secrets.${secret}`)); + } + assert.equal(workflow.match(/secrets\.PROPR_DESKTOP_UPDATE_PRIVATE_KEY/g)?.length, 1); + assert.ok(signing.includes('secrets.PROPR_DESKTOP_UPDATE_PRIVATE_KEY')); + assert.match(signing, /needs: \[preflight, release-finalize\]/); + assert.match(signing, /environment:\s+name: desktop-release/); + }); + + test('fails closed for every production signing, notarization, update, and signer condition', () => { + const production = job('release-package', 'release-finalize'); + for (const field of [ + 'CERTIFICATE_P12_BASE64', + 'CERTIFICATE_PASSWORD', + 'APPLE_API_KEY_P8_BASE64', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER_ID', + 'UPDATE_MAC_SIGNING_IDENTITY', + 'UPDATE_MAC_TEAM_ID', + 'CERTIFICATE_PFX_BASE64', + 'UPDATE_WINDOWS_SIGNING_IDENTITY', + 'UPDATE_WINDOWS_SIGNER_PINS', + 'UPDATE_PUBLIC_KEY', + 'UPDATE_MANIFEST_URL', + ]) assert.ok(production.includes(field), `missing fail-closed production field ${field}`); + assert.match( + production, + /for name in CERTIFICATE_P12_BASE64 CERTIFICATE_PASSWORD APPLE_API_KEY_P8_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER_ID UPDATE_MAC_SIGNING_IDENTITY UPDATE_MAC_TEAM_ID; do\s+test -n "\$\{!name\}"/, + ); + assert.match(production, /foreach \(\$entry in \$values\.GetEnumerator\(\)\) \{ if \(!\$entry\.Value\) \{ throw/); + assert.ok(!production.includes('signing_present')); + assert.ok(!production.includes('notarization_present')); + assert.match(production, /Production updates require a code-signed build/); + assert.match(production, /codesign --verify --deep --strict/); + assert.match(production, /spctl --assess/); + assert.match(production, /stapler validate/); + assert.match(production, /Authenticode signer does not match the configured build pin/); + assert.match(production, /TimeStamperCertificate/); + assert.match(production, /CertificateSha256/); + assert.match(production, /SpkiSha256/); + assert.match(production, /Windows artifacts have mixed Authenticode signers/); + assert.match(production, /certificate\|spki\)-sha256:\[a-f0-9\]\{64\}/); + assert.match(production, /release-architecture\.mjs inspect[\s\S]*--kind nupkg[\s\S]*lib\/net45\/propr-desktop\.exe/); + assert.ok( + production.indexOf('release-architecture.mjs inspect') < production.indexOf('Expand-Archive'), + 'the complete NUPKG must be validated before any executable is extracted or inspected', + ); + assert.match(production, /PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS: '1'/); + }); + + test('rechecks package architecture in staging and finalization and publishes only signed new releases', () => { + assert.equal(workflow.match(platformArchitecturePattern)?.length, 12); + assert.equal(workflow.match(/release-artifacts\.mjs stage/g)?.length, 2); + assert.equal(workflow.match(/release-artifacts\.mjs finalize/g)?.length, 2); + assert.match(job('finalize', 'preflight'), /needs: \[validation-version, package\]/); + assert.match(job('release-finalize', 'sign'), /needs: \[preflight, release-package\]/); + assert.match(workflow, /p7zip-full rpm/); + const publish = job('publish'); + assert.match(publish, /test -s desktop-release-final\/desktop-release\.json\.sig/); + assert.match(publish, /ref: \$\{\{ needs\.preflight\.outputs\.release_sha \}\}/); + assert.match(publish, /release-publish\.mjs/); + assert.ok(!publish.includes('gh release create')); + assert.ok(!publish.includes('desktop-release-final/*')); + assert.ok(!publish.includes('--clobber')); + assert.ok(!publish.includes('gh release upload')); + }); + + test('retains the exact native matrix when the workflow checkout uses CRLF', () => { + const crlfFixture = workflow.replaceAll('\n', '\r\n'); + const normalizedFixture = normalizeWorkflowText(crlfFixture); + assert.equal(normalizedFixture.match(platformArchitecturePattern)?.length, 12); + assert.equal(normalizedFixture, workflow); + }); + + test('runs the native DMG layout suite on both macOS architectures', () => { + for (const [jobName, section] of [ + ['unsigned validation', job('package', 'finalize')], + ['trusted production', job('release-package', 'release-finalize')], + ] as const) { + assert.equal(section.match(platformArchitecturePattern)?.length, 6, `${jobName} must retain all six native jobs`); + assert.match(section, /- platform: darwin\n\s+arch: x64\n\s+runner: macos-15-intel/, `${jobName} is missing native macOS x64`); + assert.match(section, /- platform: darwin\n\s+arch: arm64\n\s+runner: macos-15/, `${jobName} is missing native macOS arm64`); + assert.match( + section, + /- name: Typecheck and test (?:unsigned|production) desktop runtime\n\s+shell: bash\n\s+run: \|\n\s+npm run desktop:typecheck\n\s+npm run desktop:test/, + `${jobName} must run the complete desktop tests without a platform condition`, + ); + assert.match(section, /Prove private-snapshot native DMG mounting is available/); + assert.match(section, /release-artifacts\.mjs probe-dmg-private-snapshot-isolation/); + assert.match(section, /probe-dmg-private-snapshot-isolation[\s\S]*--arch "\$\{\{ matrix\.arch \}\}"/); + assert.match(section, /Stage architecture(?:-verified| and signer verified) .* with native DMG mount evidence/); + assert.match(section, /release-artifacts\.mjs stage[\s\S]*--platform "\$\{\{ matrix\.platform \}\}"[\s\S]*--arch "\$\{\{ matrix\.arch \}\}"/); + assert.match(section, /Expected \$\{process\.env\.EXPECTED_PLATFORM\}-\$\{process\.env\.EXPECTED_ARCH\}/); + } + assert.equal(workflow.match(/release-artifacts\.mjs probe-dmg-private-snapshot-isolation/g)?.length, 2); + assert.ok(!releaseArchitecture.includes('probe-dmg-descriptor')); + assert.ok(!releaseArchitecture.includes("['attach', '-readonly', '-nobrowse', '-mountpoint', directory, '/dev/fd/3']")); + assert.match(releaseArtifacts, /fsConstants\.O_RDONLY \| fsConstants\.O_NOFOLLOW \| \(privateSnapshot \? 0 : fsConstants\.O_NONBLOCK\)/); + assert.match(releaseArtifacts, /mkdtemp\(join\(tmpdir\(\), 'propr-dmg-snapshot-'\)\)/); + assert.match(releaseArtifacts, /fsConstants\.O_WRONLY \| fsConstants\.O_CREAT \| fsConstants\.O_EXCL \| fsConstants\.O_NOFOLLOW/); + assert.match(releaseArtifacts, /\(pathStats\.mode & 0o777n\) !== 0o600n/); + assert.match(releaseArtifacts, /pathStats\.nlink !== 1n/); + assert.ok(!releaseArtifacts.includes('modified: stats.mtimeNs')); + assert.ok(!releaseArtifacts.includes('changed: stats.ctimeNs')); + assert.match(releaseArchitecture, /const HDIUTIL = '\/usr\/bin\/hdiutil'/); + assert.match(releaseArchitecture, /HDIUTIL, \['attach', '-readonly', '-nobrowse', '-mountpoint', directory, privatePath\]/); + assert.match(releaseArchitecture, /try \{\n\s+if \(mounted\) await execFile\(HDIUTIL, \['detach', directory\]\);\n\s+\} finally \{\n\s+await rm\(directory/); + assert.match(verifyDarwinImage, /const HDIUTIL = '\/usr\/bin\/hdiutil'/); + assert.match(verifyDarwinImage, /await chmod\(root, 0o500\)/); + assert.match(verifyDarwinImage, /await run\(HDIUTIL, \['verify', snapshot\.path\]\)/); + assert.match(makeDmg, /for \(let attempt = 0; attempt < 2 && !created; attempt \+= 1\)/); + assert.match(makeDmg, /\^hdiutil: create failed - Resource busy\\s\*\$/); + assert.match(makeDmg, /await rename\(temporaryOutput, outputPath\)/); + assert.match(makeDmg, /try \{ await rm\(temporaryOutput, \{ force: true \}\); \} finally \{\n\s+await rm\(stagingDirectory/); + assert.ok( + releaseArchitecture.indexOf("['attach', '-readonly', '-nobrowse', '-mountpoint', directory, privatePath]") + < releaseArchitecture.indexOf('inspectDmgLayout({ root: directory'), + 'native DMG bytes must be mounted read-only before layout validation', + ); + assert.ok( + releaseArchitecture.indexOf('inspectDmgLayout({ root: directory') + < releaseArchitecture.indexOf('nativeValidation: nativeDmgLayoutEvidence'), + 'native layout evidence must be produced only after the real layout validator succeeds', + ); + assert.ok( + releaseArtifacts.indexOf('const inspection = await inspectArchitecture') + < releaseArtifacts.indexOf('createNativeDmgEvidence({'), + 'staging must inspect the copied canonical DMG before binding native evidence', + ); + }); + + test('runs the short-argv native Windows broker smoke before both x64 and arm64 suites', () => { + assert.equal(workflow.match(/Probe Windows authority production C# before desktop suite/g)?.length, 2); + assert.equal(workflow.match(/Smoke Windows authority broker before the runtime suite/g)?.length, 2); + for (const [jobName, section] of [ + ['unsigned validation', job('package', 'finalize')], + ['trusted production', job('release-package', 'release-finalize')], + ] as const) { + assert.match(section, /- platform: win32\n\s+arch: x64\n\s+runner: windows-2025/); + assert.match(section, /- platform: win32\n\s+arch: arm64\n\s+runner: windows-11-arm/); + assert.match(section, /Probe Windows authority production C# before desktop suite\n\s+if: matrix\.platform == 'win32'/); + assert.match(section, /Smoke Windows authority broker before the runtime suite\n\s+if: matrix\.platform == 'win32'/); + assert.ok( + section.indexOf('Probe Windows authority production C# before desktop suite') + < section.indexOf('Smoke Windows authority broker before the runtime suite'), + `${jobName} must build and directly launch the exact helper before starting the production broker`, + ); + assert.ok( + section.indexOf('Smoke Windows authority broker before the runtime suite') + < section.indexOf(`Typecheck and test ${jobName === 'unsigned validation' ? 'unsigned' : 'production'} desktop runtime`), + `${jobName} must build, authenticate, and exercise the compiled broker before the complete runtime suite`, + ); + const packagedProbe = jobName === 'unsigned validation' + ? 'Directly launch packaged Windows authority helper to READY' + : 'Directly launch signed packaged Windows authority helper to READY'; + assert.ok( + section.indexOf(packagedProbe) + < section.indexOf(`Typecheck and test ${jobName === 'unsigned validation' ? 'unsigned' : 'production'} desktop runtime`), + `${jobName} must directly exercise the packaged helper before the complete runtime suite`, + ); + } + assert.match(workflow, /PROPR_DESKTOP_PRODUCTION_RELEASE=0 npm run desktop:broker:build/g); + assert.match(windowsAuthority, /helper\.launcher\.launch\(\{/); + assert.match(windowsAuthority, /ready\.imageVolumeSerial !== child\.imageVolumeSerial/); + assert.match(windowsNativeLauncher, /CreateFileW\(path\.c_str\(\), GENERIC_READ \| READ_CONTROL, FILE_SHARE_READ/); + assert.match(windowsNativeLauncher, /CREATE_SUSPENDED \| CREATE_NO_WINDOW \| EXTENDED_STARTUPINFO_PRESENT/); + assert.match(windowsNativeLauncher, /PROC_THREAD_ATTRIBUTE_HANDLE_LIST/); + assert.match(windowsNativeLauncher, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE/); + assert.match(windowsNativeLauncher, /JOB_OBJECT_LIMIT_ACTIVE_PROCESS/); + assert.match(windowsNativeLauncher, /ActiveProcessLimit = 1/); + assert.match(windowsNativeLauncher, /AssignProcessToJobObject/); + assert.match(windowsNativeLauncher, /QueryFullProcessImageNameW/); + assert.match(windowsNativeLauncher, /SameIdentity\(held_id, loaded_id\)/); + assert.match(windowsNativeLauncher, /VerifyPinnedSignature/); + assert.match(windowsNativeLauncher, /CompileHeld/); + assert.match(windowsNativeLauncher, /VerifyMicrosoftCompilerInput/); + assert.match(windowsNativeLauncher, /CryptCATAdminEnumCatalogFromHash/); + assert.match(windowsNativeLauncher, /CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED[^_]/); + assert.match(windowsNativeLauncher, /SignerContent::StandaloneCatalog/); + assert.match(windowsNativeLauncher, /SignerContent::EmbeddedPe/); + assert.match(windowsNativeLauncher, /CreateProcessW\(paths\[0\]\.c_str\(\)/); + assert.match(windowsNativeLauncher, /HANDLE inherited\[\] = \{child_stdin, child_stdout, child_stderr\}/); + assert.match(windowsNativeLauncher, /SameIdentity\(identities\[0\], loaded_id\)/); + assert.match(windowsNativeLauncher, /DangerousUntrustedAcl/); + assert.match(windowsAuthority, /GLOBALROOT\\SystemRoot\\System32\\WindowsPowerShell\\v1\.0\\powershell\.exe/); + assert.doesNotMatch(windowsAuthority, /process\.env\.(?:SystemRoot|windir|COMSPEC|PATH)/i); + assert.match(windowsAuthority, /const child = spawn\(KERNEL_SYSTEM_POWERSHELL/); + assert.match(windowsAuthority, /env: \{\}/); + assert.ok(!windowsAuthority.includes('writeBootstrap')); + assert.ok(!windowsAuthority.includes('brokerSource')); + assert.match(windowsAuthority, /await session\.write\(JSON\.stringify\(\{/); + assert.match(windowsAuthority, /BROKER_STARTUP_TIMEOUT_MS = 60_000/); + assert.match(windowsAuthoritySource, /"type", "ready"/); + assert.match(windowsAuthoritySource, /"nativeSmoke", true/); + assert.match(windowsAuthoritySource, /"compileCount", 1/); + for (const stage of [ + 'BUILD_COMPILER', + 'BUILD_SOURCE', + 'BUILD_OUTPUT', + 'TRANSPORT_SPAWN', + 'MANIFEST', + 'HELPER_OPEN', + 'HELPER_OWNER_DACL', + 'HELPER_REPARSE', + 'HELPER_IDENTITY', + 'HELPER_HASH', + 'PROTOCOL_INIT', + 'READY', + ]) assert.match(windowsAuthority, new RegExp(`'${stage}'`)); + assert.match(windowsAuthorityBuild, /Microsoft\.NET', layout, 'v4\.0\.30319'/); + assert.match(windowsAuthorityBuild, /nativeLauncher\.compileHeld\(\{/); + assert.match(windowsNativeLauncher, /\/platform:anycpu/); + assert.doesNotMatch(windowsAuthorityBuild, /execFileAsync\(compiler/); + assert.doesNotMatch(windowsAuthorityBuild, /require\(launcher\.path\)/); + assert.doesNotMatch(windowsAuthority, /require\(launcherProof\.path\)/); + assert.match(windowsAuthority, /require\(bootstrapProof\.path\)/); + assert.match(windowsAuthority, /bootstrap\.loadVerifiedModule\(\{/); + assert.match(forgeConfig, /extraResource: \[resolve\('build', 'windows-authority'\)\]/); + assert.match(forgeConfig, /refreshPackagedWindowsAuthorityManifest/); + assert.match(windowsAuthority, /purpose: BrokerPurpose/); + assert.match(windowsAuthority, /expectedBytes: number \| null/); + }); + + test('installs the full machine-wide Windows artifact and exercises its protected authority on both architectures', () => { + assert.equal(workflow.match(/Install and exercise machine-protected Windows authority/g)?.length, 1); + assert.equal(workflow.match(/Install and exercise signed machine-protected Windows authority/g)?.length, 1); + assert.equal(workflow.match(/test-installed-windows-authority\.ps1/g)?.length, 2); + assert.match(workflow, /\*Machine-Setup\.msi/); + assert.match(workflow, /-Architecture '\$\{\{ matrix\.arch \}\}'/); + assert.match(forgeConfig, /postMake:/); + assert.match(forgeConfig, /buildWindowsMachineInstaller/); + assert.match(forgeConfig, /noMsi: true/); + assert.match(forgeConfig, /Machine-Setup\.msi/); + assert.match(windowsMachineInstaller, /InstallScope="perMachine"/); + assert.match(windowsMachineInstaller, /\/inheritance:r/); + assert.match(windowsMachineInstaller, /\/setowner \*S-1-5-18/); + assert.match(windowsMachineInstaller, /\*S-1-5-32-545:\(OI\)\(CI\)RX/); + assert.doesNotMatch(windowsMachineInstaller, /\*S-1-5-32-545:\(OI\)\(CI\)(?:M|F)/); + assert.match(installedWindowsAuthorityTest, /AreAccessRulesProtected/); + assert.match(installedWindowsAuthorityTest, /--propr-authority-smoke/); + assert.match(installedWindowsAuthorityTest, /-Credential \$credential/); + assert.match(installedWindowsAuthorityTest, /OpenWrite/); + assert.match(installedWindowsAuthorityTest, /File\]::Move/); + assert.match(installedWindowsAuthorityTest, /File\]::Delete/); + }); +}); diff --git a/apps/desktop/src/signed-updates.test.ts b/apps/desktop/src/signed-updates.test.ts new file mode 100644 index 000000000..a3615ede7 --- /dev/null +++ b/apps/desktop/src/signed-updates.test.ts @@ -0,0 +1,1168 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import { access, chmod, link, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { describe, test } from 'node:test'; +import { + applySignedUpdate, + canonicalPosixFileIdentity, + checkForSignedUpdates, + collectUpdateCacheQuarantinesForTest, + downloadBoundedUpdateFile, + fetchBoundedUpdateBytes, + parseSquirrelReleaseEntry, + posixAuthorityIsPrivate, + quarantineUpdateCacheNamespaceForTest, + SIGNED_UPDATE_CACHE_POLICY, + SIGNED_UPDATE_DOWNLOAD_LIMITS, + sameExactFileIdentity, + type SignedUpdateManifest, + type SignedUpdateRequest, + validateMacOSUpdateApplicationLayout, + verifySignedUpdateManifest, +} from './signed-updates'; +import { ensureWindowsPrivateDirectory, protectWindowsPrivateFile } from './windows-update-authority'; + +const execFileAsync = promisify(execFile); + +const keys = generateKeyPairSync('ed25519'); +const publicKey = keys.publicKey.export({ format: 'der', type: 'spki' }).toString('base64'); +const certificateSha256 = '1'.repeat(64); +const spkiSha256 = '2'.repeat(64); +const artifact = Buffer.from('signed windows package bytes'); +const artifactUrl = 'https://updates.example.test/win32/x64/ProPR-Desktop-1.2.4-windows-x64-full.nupkg'; +const artifactSha1 = createHash('sha1').update(artifact).digest('hex'); +const feed = Buffer.from(`${artifactSha1} ProPR-Desktop-1.2.4-windows-x64-full.nupkg ${artifact.length}\r\n`); +const bytes = (url: string, value: Buffer) => ({ + url, + size: value.length, + sha256: createHash('sha256').update(value).digest('hex'), +}); +const manifest: SignedUpdateManifest = { + schemaVersion: 2, + channel: 'stable', + manifestUrl: 'https://updates.example.test/stable/desktop-release.json', + windowsSignerPins: [`certificate-sha256:${certificateSha256}`], + version: '1.2.4', + tag: 'desktop-v1.2.4', + publishedAt: '2026-08-29T12:00:00.000Z', + feeds: { + 'win32-x64': { + target: 'win32-x64', + version: '1.2.4', + feed: bytes('https://updates.example.test/win32/x64/RELEASES', feed), + artifact: { + ...bytes(artifactUrl, artifact), + fileName: 'ProPR-Desktop-1.2.4-windows-x64-full.nupkg', + kind: 'nupkg', + }, + signer: { + type: 'authenticode-subject', + identity: 'CN=Example Publisher', + certificateSha256, + spkiSha256, + }, + }, + }, +}; + +const signed = (value: unknown = manifest) => { + const payload = Buffer.from(`${JSON.stringify(value)}\n`); + return { payload, signature: sign(null, payload, keys.privateKey).toString('base64') }; +}; + +const response = ( + url: string, + chunks: Uint8Array[], + { headers, status = 200 }: { headers?: HeadersInit; status?: number } = {}, +): Response => { + const value = new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }), { headers, status }); + Object.defineProperty(value, 'url', { value: url }); + return value; +}; + +const byteResponse = (url: string, value: Buffer): Response => response( + url, + [value], + { headers: { 'content-length': String(value.length) } }, +); + +const fetcher = (payload: Buffer, signature: string, overrides: Record = {}): SignedUpdateRequest => async (url: string) => { + if (url.endsWith('desktop-release.json.sig')) return byteResponse(url, Buffer.from(signature)); + if (url.endsWith('desktop-release.json')) return byteResponse(url, payload); + if (url === manifest.feeds['win32-x64'].feed.url) return byteResponse(url, overrides.feed ?? feed); + if (url === artifactUrl) return byteResponse(url, overrides.artifact ?? artifact); + throw new Error(`Unexpected URL ${url}`); +}; + +const config = { + manifestUrl: 'https://updates.example.test/stable/desktop-release.json', + publicKey, + signingIdentity: 'CN=Example Publisher', + windowsSignerPins: [`certificate-sha256:${certificateSha256}`], +}; + +const windowsArtifact = manifest.feeds['win32-x64'].artifact; +const windowsSigner = async () => ({ + type: 'authenticode-subject' as const, + identity: 'CN=Example Publisher', + certificateSha256, + spkiSha256, +}); + +test('security identities preserve adjacent device/inode values above Number precision', () => { + const adjacent = 2n ** 53n; + const first = canonicalPosixFileIdentity(adjacent, adjacent + 1n); + const second = canonicalPosixFileIdentity(adjacent, adjacent + 2n); + assert.notEqual(first.inode, second.inode); + assert.equal(sameExactFileIdentity(first, first), true); + assert.equal(sameExactFileIdentity(first, second), false); + assert.equal(posixAuthorityIsPrivate(1000n, 0o100600n, undefined), false); + assert.equal(posixAuthorityIsPrivate(1000n, 0o100600n, 1000n), true); + assert.equal(posixAuthorityIsPrivate(1000n, 0o100644n, 1000n), false); +}); + +describe('runtime Squirrel RELEASES binding', () => { + test('accepts a canonical Windows Squirrel record and canonicalizes its SHA-1', () => { + const entry = parseSquirrelReleaseEntry( + Buffer.from(`${artifactSha1.toUpperCase()} ${windowsArtifact.fileName} ${artifact.length}\r\n`), + '1.2.4', + windowsArtifact, + ); + assert.deepEqual(entry, { sha1: artifactSha1, fileName: windowsArtifact.fileName, size: artifact.length }); + }); + + test('rejects duplicate, ambiguous, wrong-name/version/size, traversal, case, and algorithm records', () => { + const valid = `${artifactSha1} ${windowsArtifact.fileName} ${artifact.length}`; + const hostile = [ + `${valid}\n${valid}\n`, + `${valid}\n${artifactSha1} ${windowsArtifact.fileName.toUpperCase()} ${artifact.length}\n`, + `${artifactSha1} ProPR-Desktop-1.2.5-windows-x64-full.nupkg ${artifact.length}\n`, + `${artifactSha1} other.nupkg ${artifact.length}\n`, + `${artifactSha1} ${windowsArtifact.fileName} ${artifact.length + 1}\n`, + `${artifactSha1} ../${windowsArtifact.fileName} ${artifact.length}\n`, + `sha1:${artifactSha1} ${windowsArtifact.fileName} ${artifact.length}\n`, + `${artifactSha1} ${windowsArtifact.fileName} ${artifact.length}\n`, + ]; + for (const candidate of hostile) { + assert.throws( + () => parseSquirrelReleaseEntry(Buffer.from(candidate), '1.2.4', windowsArtifact), + /Signed Windows update feed is invalid/, + ); + } + }); + + test('rejects a RELEASES SHA-1 that does not bind the signed SHA-256 package bytes', async () => { + const mismatched = Buffer.from(`${'0'.repeat(40)} ${windowsArtifact.fileName} ${artifact.length}\n`); + const changed = structuredClone(manifest); + changed.feeds['win32-x64'].feed = bytes(manifest.feeds['win32-x64'].feed.url, mismatched); + const release = signed(changed); + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(release.payload, release.signature, { feed: mismatched }), + verifyNativeSigner: async () => assert.fail('mismatched SHA-1 must fail before signer verification'), + }), + /does not match Squirrel metadata/, + ); + }); +}); + +describe('signed desktop updates', () => { + test('accepts only the real canonical macOS application at the ZIP root', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-macos-update-layout-test-')); + try { + const valid = join(directory, 'valid'); + await mkdir(join(valid, 'propr-desktop.app'), { recursive: true }); + assert.equal( + await validateMacOSUpdateApplicationLayout(valid), + join(valid, 'propr-desktop.app'), + ); + + const decoy = join(directory, 'decoy'); + await mkdir(join(decoy, 'propr-desktop.app'), { recursive: true }); + await mkdir(join(decoy, 'signed-decoy.app')); + await assert.rejects( + validateMacOSUpdateApplicationLayout(decoy), + /ambiguous application layout/, + ); + + const linked = join(directory, 'linked'); + await mkdir(linked); + await mkdir(join(directory, 'real.app')); + await symlink('../real.app', join(linked, 'propr-desktop.app')); + await assert.rejects( + validateMacOSUpdateApplicationLayout(linked), + /must be a real directory/, + ); + + const missing = join(directory, 'missing'); + await mkdir(missing); + await assert.rejects( + validateMacOSUpdateApplicationLayout(missing), + /missing the canonical propr-desktop\.app bundle/, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('verifies the exact published manifest bytes', () => { + const release = signed(); + assert.equal(verifySignedUpdateManifest(release.payload, release.signature, publicKey).version, '1.2.4'); + assert.throws( + () => verifySignedUpdateManifest(Buffer.from(release.payload.toString().replace('1.2.4', '1.2.5')), release.signature, publicKey), + /signature verification failed/, + ); + }); + + test('rejects a signed artifact size above the global runtime limit', () => { + const oversized = structuredClone(manifest); + oversized.feeds['win32-x64'].artifact.size = SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes + 1; + const release = signed(oversized); + assert.throws( + () => verifySignedUpdateManifest(release.payload, release.signature, publicKey), + /artifact exceeds the runtime download limit/, + ); + }); + + test('checks exact feed, artifact, and native signer without invoking Electron autoUpdater', async () => { + const release = signed(); + let verifiedBytes: Buffer | undefined; + let verifiedPath: string | undefined; + const result = await checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(release.payload, release.signature), + verifyNativeSigner: async packagePath => { + verifiedPath = packagePath; + verifiedBytes = await readFile(packagePath); + return { type: 'authenticode-subject', identity: 'CN=Example Publisher', certificateSha256, spkiSha256 }; + }, + }); + assert.equal(result, 'available'); + assert.deepEqual(verifiedBytes, artifact); + await assert.rejects(access(verifiedPath!)); + }); + + test('keeps macOS checks non-installing while caching notarized signer-verified bytes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-macos-update-cache-test-')); + const macArtifact = Buffer.from('signed macOS ZIP bytes'); + const macArtifactUrl = 'https://updates.example.test/darwin/x64/ProPR-Desktop-1.2.4-macos-x64-zip'; + const macFeed = Buffer.from(JSON.stringify({ url: macArtifactUrl, name: '1.2.4' })); + const macManifest = structuredClone(manifest); + macManifest.feeds['darwin-x64'] = { + target: 'darwin-x64', + version: '1.2.4', + feed: bytes('https://updates.example.test/darwin/x64/RELEASES.json', macFeed), + artifact: { + ...bytes(macArtifactUrl, macArtifact), + fileName: 'ProPR-Desktop-1.2.4-macos-x64-zip', + kind: 'zip', + }, + signer: { + type: 'apple-team-id', + identity: 'TEAMID1234', + designatedRequirement: 'designated => identifier "com.propr.desktop" and anchor apple generic', + }, + }; + const release = signed(macManifest); + let artifactRequests = 0; + const request: SignedUpdateRequest = async url => { + if (url.endsWith('desktop-release.json.sig')) return byteResponse(url, Buffer.from(release.signature)); + if (url.endsWith('desktop-release.json')) return byteResponse(url, release.payload); + if (url === macManifest.feeds['darwin-x64'].feed.url) return byteResponse(url, macFeed); + if (url === macArtifactUrl) { + artifactRequests += 1; + return byteResponse(url, macArtifact); + } + throw new Error(`Unexpected URL ${url}`); + }; + let installs = 0; + try { + assert.equal(await checkForSignedUpdates({ + config: { ...config, signingIdentity: 'TEAMID1234' }, + currentVersion: '1.2.3', + platform: 'darwin', + arch: 'x64', + request, + cacheDirectory: join(directory, 'cache'), + verifyNativeSigner: async () => ({ + type: 'apple-team-id', + identity: 'TEAMID1234', + designatedRequirement: 'designated => identifier "com.propr.desktop" and anchor apple generic', + }), + }), 'available'); + assert.equal(installs, 0); + assert.equal(artifactRequests, 1); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('rejects tampered native feed bytes', async () => { + const release = signed(); + const tamperedFeed = Buffer.from(feed); + tamperedFeed[0] = tamperedFeed[0] === 48 ? 49 : 48; + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(release.payload, release.signature, { feed: tamperedFeed }), + verifyNativeSigner: async () => assert.fail('must not inspect a package from a tampered feed'), + }), + /feed SHA-256/i, + ); + }); + + test('rejects tampered artifact bytes before native signer inspection', async () => { + const release = signed(); + const tamperedArtifact = Buffer.from(artifact); + tamperedArtifact[0] ^= 1; + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(release.payload, release.signature, { artifact: tamperedArtifact }), + verifyNativeSigner: async () => assert.fail('must not inspect a tampered package'), + }), + /artifact SHA-256/i, + ); + }); + + test('rejects the actual native signer when it differs from the signed build pin', async () => { + const release = signed(); + let inspectedPath: string | undefined; + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(release.payload, release.signature), + verifyNativeSigner: async packagePath => { + inspectedPath = packagePath; + return { type: 'authenticode-subject', identity: 'CN=Attacker', certificateSha256, spkiSha256 }; + }, + }), + /artifact signer does not match/, + ); + await assert.rejects(access(inspectedPath!)); + }); + + test('rejects same-subject different-key signers and tampered or missing pin evidence', async () => { + const release = signed(); + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(release.payload, release.signature), + verifyNativeSigner: async () => ({ + type: 'authenticode-subject', + identity: 'CN=Example Publisher', + certificateSha256: '3'.repeat(64), + spkiSha256: '4'.repeat(64), + }), + }), + /artifact signer does not match/, + ); + + const tamperedEvidence = structuredClone(manifest); + tamperedEvidence.feeds['win32-x64'].signer.certificateSha256 = '3'.repeat(64); + tamperedEvidence.feeds['win32-x64'].signer.spkiSha256 = '4'.repeat(64); + const tamperedRelease = signed(tamperedEvidence); + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(tamperedRelease.payload, tamperedRelease.signature), + }), + /fingerprint is not in the embedded allowlist/, + ); + + const alteredPolicy = structuredClone(manifest); + alteredPolicy.windowsSignerPins = [`spki-sha256:${spkiSha256}`]; + const alteredPolicyRelease = signed(alteredPolicy); + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(alteredPolicyRelease.payload, alteredPolicyRelease.signature), + }), + /pin policy does not match the signed application policy/, + ); + + await assert.rejects( + checkForSignedUpdates({ + config: { ...config, windowsSignerPins: [] }, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(release.payload, release.signature), + }), + /signer pin allowlist.*required/, + ); + + const malformedEvidence = structuredClone(manifest) as unknown as Record; + malformedEvidence.feeds['win32-x64'].signer.spkiSha256 = 'not-a-fingerprint'; + const malformedRelease = signed(malformedEvidence); + assert.throws( + () => verifySignedUpdateManifest(malformedRelease.payload, malformedRelease.signature, publicKey), + /fingerprint evidence is invalid/, + ); + }); + + test('rejects wrong target, version, and architecture bindings', async () => { + const wrongTarget = structuredClone(manifest) as unknown as Record; + wrongTarget.feeds['win32-x64'].target = 'win32-arm64'; + const targetRelease = signed(wrongTarget); + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(targetRelease.payload, targetRelease.signature), + }), + /exact target and version/, + ); + + const wrongVersion = structuredClone(manifest) as unknown as Record; + wrongVersion.feeds['win32-x64'].version = '1.2.3'; + const versionRelease = signed(wrongVersion); + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: fetcher(versionRelease.payload, versionRelease.signature), + }), + /exact target and version/, + ); + + const release = signed(); + await assert.rejects( + checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'arm64', + request: fetcher(release.payload, release.signature), + }), + /does not contain a feed for win32-arm64/, + ); + }); + + test('rejects manifest query strings before resolving the pathname .sig companion', async () => { + await assert.rejects( + checkForSignedUpdates({ + config: { ...config, manifestUrl: `${config.manifestUrl}?channel=stable` }, + currentVersion: '1.2.3', + platform: 'win32', + arch: 'x64', + request: async () => assert.fail('query-bearing manifest URL must not be fetched'), + }), + /without credentials, a fragment, or a query/, + ); + }); + + test('does not fetch update bytes for current or unsupported builds', async () => { + const release = signed(); + let artifactFetched = false; + const currentFetcher: SignedUpdateRequest = async (url, init) => { + if (!url.includes('desktop-release.json')) artifactFetched = true; + return fetcher(release.payload, release.signature)(url, init); + }; + assert.equal(await checkForSignedUpdates({ + config, + currentVersion: '1.2.4', + platform: 'win32', + arch: 'x64', + request: currentFetcher, + }), 'current'); + assert.equal(await checkForSignedUpdates({ + config, + currentVersion: '1.2.3', + platform: 'linux', + arch: 'x64', + request: async () => assert.fail('unsupported builds must not fetch metadata'), + }), 'unsupported'); + assert.equal(artifactFetched, false); + }); +}); + +describe('verified update artifact cache', () => { + const makeOptions = ( + cacheDirectory: string, + request: SignedUpdateRequest, + extra: Partial[0]> = {}, + ) => ({ + config, + currentVersion: '1.2.3', + platform: 'win32' as const, + arch: 'x64', + request, + cacheDirectory, + verifyNativeSigner: windowsSigner, + ...extra, + }); + + const countingFetcher = (release: ReturnType) => { + let artifactRequests = 0; + const base = fetcher(release.payload, release.signature); + return { + request: (async (url, init) => { + if (url === artifactUrl) artifactRequests += 1; + return base(url, init); + }) as SignedUpdateRequest, + count: () => artifactRequests, + }; + }; + + test('check then explicit apply downloads one artifact and check-only never installs', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + let installs = 0; + try { + assert.equal(await checkForSignedUpdates(makeOptions(cacheDirectory, counted.request)), 'available'); + assert.equal(installs, 0); + assert.equal(counted.count(), 1); + assert.equal(await applySignedUpdate({ + ...makeOptions(cacheDirectory, counted.request), + applyHeldArtifact: async source => { + installs += 1; + assert.deepEqual(await source.read(0, artifact.length), artifact); + assert.deepEqual(source.feedBytes, feed); + }, + installVerifiedArtifact: verified => { + assert.deepEqual(Object.keys(verified).sort(), ['apply', 'artifact', 'feedBytes']); + assert.equal('packagePath' in verified, false); + return verified.apply(); + }, + }), 'applied'); + assert.equal(installs, 1); + assert.equal(counted.count(), 1); + await assert.rejects(access(join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName))); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('fails automatic apply closed when no held-capability platform adapter exists', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + try { + const options = makeOptions(cacheDirectory, counted.request); + await checkForSignedUpdates(options); + await assert.rejects( + applySignedUpdate({ + ...options, + installVerifiedArtifact: async () => assert.fail('an unavailable platform adapter must not receive a path'), + }), + /Automatic update apply is unavailable/, + ); + assert.equal(counted.count(), 1); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('expiry and corruption each cause exactly one safe artifact redownload', async t => { + for (const scenario of ['expired', 'corrupt'] as const) { + await t.test(scenario, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + let now = 10_000; + try { + const options = makeOptions(cacheDirectory, counted.request, { now: () => now }); + await checkForSignedUpdates(options); + if (scenario === 'expired') now += SIGNED_UPDATE_CACHE_POLICY.expiryMs + 1; + else await writeFile( + join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName, SIGNED_UPDATE_CACHE_POLICY.artifactName), + Buffer.alloc(artifact.length, 0x41), + ); + await applySignedUpdate({ + ...options, + applyHeldArtifact: async source => assert.deepEqual(await source.read(0, artifact.length), artifact), + installVerifiedArtifact: verified => verified.apply(), + }); + assert.equal(counted.count(), 2); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + } + }); + + test('origin, channel, and version cache-key mismatches each force one redownload', async t => { + for (const field of ['origin', 'channel', 'version'] as const) { + await t.test(field, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + try { + const options = makeOptions(cacheDirectory, counted.request); + await checkForSignedUpdates(options); + const metadataPath = join( + cacheDirectory, + SIGNED_UPDATE_CACHE_POLICY.entryName, + SIGNED_UPDATE_CACHE_POLICY.metadataName, + ); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')); + metadata.key[field] = field === 'origin' ? 'https://other.example.test' : field === 'channel' ? 'beta' : '9.9.9'; + await writeFile(metadataPath, `${JSON.stringify(metadata)}\n`, { mode: 0o600 }); + await applySignedUpdate({ + ...options, + applyHeldArtifact: async source => assert.deepEqual(await source.read(0, artifact.length), artifact), + installVerifiedArtifact: verified => verified.apply(), + }); + assert.equal(counted.count(), 2); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + } + }); + + test('rejects symlink, hardlink, permission-broad, partial, and ABA-swapped entries', async t => { + for (const scenario of ['symlink', 'hardlink', 'permissions', 'partial', 'aba'] as const) { + await t.test(scenario, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + let attack = false; + const signer = async (packagePath: string) => { + if (attack) { + attack = false; + const held = `${packagePath}.held`; + await rename(packagePath, held); + await writeFile(packagePath, Buffer.alloc(artifact.length, 0x42), { mode: 0o600 }); + await rm(packagePath); + await rename(held, packagePath); + } + return windowsSigner(); + }; + try { + const options = makeOptions(cacheDirectory, counted.request, { verifyNativeSigner: signer }); + if (scenario === 'partial') { + if (process.platform === 'win32') await ensureWindowsPrivateDirectory(cacheDirectory); + await mkdir(join(cacheDirectory, '.partial-crash'), { recursive: true, mode: 0o700 }); + await writeFile(join(cacheDirectory, '.partial-crash', 'artifact'), 'partial'); + } + await checkForSignedUpdates(options); + const artifactPath = join( + cacheDirectory, + SIGNED_UPDATE_CACHE_POLICY.entryName, + SIGNED_UPDATE_CACHE_POLICY.artifactName, + ); + if (scenario === 'symlink') { + const decoy = join(directory, 'decoy'); + await writeFile(decoy, artifact); + await rm(artifactPath); + await symlink(decoy, artifactPath); + } else if (scenario === 'hardlink') { + await link(artifactPath, join(directory, 'hardlink')); + } else if (scenario === 'permissions') { + if (process.platform === 'win32') { + await execFileAsync('icacls.exe', [artifactPath, '/grant', '*S-1-5-32-545:M']); + } else await chmod(artifactPath, 0o644); + } else if (scenario === 'aba') { + attack = true; + } + await applySignedUpdate({ + ...options, + applyHeldArtifact: async source => assert.deepEqual(await source.read(0, artifact.length), artifact), + installVerifiedArtifact: verified => verified.apply(), + }); + assert.equal(counted.count(), scenario === 'partial' ? 1 : 2); + await assert.rejects(access(join(cacheDirectory, '.partial-crash'))); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + } + }); + + test('serializes concurrent checks and retains only the single bounded artifact', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + try { + const options = makeOptions(cacheDirectory, counted.request); + assert.deepEqual(await Promise.all([ + checkForSignedUpdates(options), + checkForSignedUpdates(options), + checkForSignedUpdates(options), + ]), ['available', 'available', 'available']); + assert.equal(counted.count(), 1); + assert.deepEqual( + (await readFile(join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName, SIGNED_UPDATE_CACHE_POLICY.artifactName))), + artifact, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('enforces the whole-cache one-entry and byte quota during concurrent cleanup', async t => { + for (const scenario of [ + 'unknown', + 'many-small', + 'over-limit', + 'long-name-total', + 'oversized', + 'nested', + 'deep-nesting', + 'symlink-loop', + 'case-collision', + ] as const) { + await t.test(scenario, async context => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-quota-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + try { + const options = makeOptions(cacheDirectory, counted.request); + await checkForSignedUpdates(options); + const entry = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + if (scenario === 'unknown') { + await writeFile(join(cacheDirectory, 'unknown'), 'x'); + } else if (scenario === 'many-small') { + await Promise.all(Array.from({ length: 32 }, (_, index) => + writeFile(join(cacheDirectory, `unknown-${index}`), 'x'))); + } else if (scenario === 'over-limit') { + await Promise.all(Array.from({ length: SIGNED_UPDATE_CACHE_POLICY.inspectionEntryCap + 8 }, (_, index) => + writeFile(join(cacheDirectory, `overflow-${index}`), 'x'))); + } else if (scenario === 'long-name-total') { + await Promise.all(Array.from({ length: 60 }, (_, index) => + writeFile(join(cacheDirectory, `${index}-${'n'.repeat(230)}`), 'x'))); + } else if (scenario === 'oversized') { + await truncate( + join(entry, SIGNED_UPDATE_CACHE_POLICY.artifactName), + SIGNED_UPDATE_CACHE_POLICY.namespaceBytes + 1, + ); + } else if (scenario === 'nested') { + await mkdir(join(entry, 'nested')); + await writeFile(join(entry, 'nested', 'unknown'), 'x'); + } else if (scenario === 'deep-nesting') { + let nested = join(entry, 'nested'); + for (let depth = 0; depth < SIGNED_UPDATE_CACHE_POLICY.inspectionDepth + 8; depth += 1) { + await mkdir(nested, { recursive: true }); + nested = join(nested, 'deeper'); + } + } else if (scenario === 'symlink-loop') { + const nested = join(entry, 'nested'); + await mkdir(nested); + await symlink(nested, join(nested, 'loop'), process.platform === 'win32' ? 'junction' : 'dir'); + } else { + const collision = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName.toUpperCase()); + try { + await mkdir(collision); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + context.skip('filesystem does not permit distinct case-colliding names'); + return; + } + throw error; + } + } + assert.deepEqual(await Promise.all([ + checkForSignedUpdates(options), + checkForSignedUpdates(options), + ]), ['available', 'available']); + assert.equal(await checkForSignedUpdates(options), 'available', 'restart must reuse only the fresh namespace'); + assert.equal(counted.count(), 2); + assert.deepEqual(await readdir(cacheDirectory), [SIGNED_UPDATE_CACHE_POLICY.entryName]); + assert.deepEqual((await readdir(entry)).sort(), [ + SIGNED_UPDATE_CACHE_POLICY.artifactName, + SIGNED_UPDATE_CACHE_POLICY.metadataName, + ].sort()); + assert.equal(SIGNED_UPDATE_CACHE_POLICY.inspectionEntryCap, 64); + assert.equal(SIGNED_UPDATE_CACHE_POLICY.inspectionDepth, 3); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + } + }); + + test('bounded quarantine collector persists progress, refuses backlog growth, and eventually completes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-quarantine-restart-')); + const cacheDirectory = join(directory, 'cache'); + const quarantineRoot = join(directory, '.cache.quarantine'); + try { + if (process.platform === 'win32') await ensureWindowsPrivateDirectory(cacheDirectory); + else await mkdir(cacheDirectory, { mode: 0o700 }); + await Promise.all(Array.from({ length: 400 }, (_, index) => + writeFile(join(cacheDirectory, `attacker-${String(index).padStart(3, '0')}`), 'x'))); + await quarantineUpdateCacheNamespaceForTest(cacheDirectory); + + await writeFile(join(cacheDirectory, 'next-invalid'), 'x'); + await assert.rejects( + quarantineUpdateCacheNamespaceForTest(cacheDirectory), + /quarantine backlog exceeds the global bound/, + 'an incomplete fixed-slot backlog must prevent accumulation', + ); + + let previousNames = -1; + let passes = 0; + while (passes < 12) { + const state = await collectUpdateCacheQuarantinesForTest(cacheDirectory); + passes += 1; + if (state.records.length === 0) break; + const names = state.records.reduce((total, record) => total + record.names, 0); + if (previousNames >= 0) { + assert.ok(names - previousNames <= SIGNED_UPDATE_CACHE_POLICY.cleanupEntryCap); + } + previousNames = names; + } + assert.ok(passes > 1, 'oversized attacker trees must require bounded restart passes'); + assert.deepEqual((await collectUpdateCacheQuarantinesForTest(cacheDirectory)).records, []); + assert.deepEqual(await readdir(quarantineRoot), ['collector.json']); + + // Once the bounded backlog is gone a later invalid namespace can rotate + // through the same fixed slots and complete without adjacent accumulation. + await quarantineUpdateCacheNamespaceForTest(cacheDirectory); + assert.deepEqual((await collectUpdateCacheQuarantinesForTest(cacheDirectory)).records, []); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('quarantine cleanup unlinks loops and resumes after a permission failure', async t => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-quarantine-hostile-')); + const cacheDirectory = join(directory, 'cache'); + const external = join(directory, 'external'); + try { + if (process.platform === 'win32') await ensureWindowsPrivateDirectory(cacheDirectory); + else await mkdir(cacheDirectory, { mode: 0o700 }); + await mkdir(external); + await writeFile(join(external, 'preserved'), 'outside'); + await symlink(external, join(cacheDirectory, 'loop'), process.platform === 'win32' ? 'junction' : 'dir'); + await quarantineUpdateCacheNamespaceForTest(cacheDirectory); + assert.equal(await readFile(join(external, 'preserved'), 'utf8'), 'outside'); + assert.deepEqual((await collectUpdateCacheQuarantinesForTest(cacheDirectory)).records, []); + + await t.test('permission failure resumes', { skip: process.platform === 'win32' }, async () => { + const blocked = join(cacheDirectory, 'blocked'); + await mkdir(blocked, { mode: 0o700 }); + await writeFile(join(blocked, 'entry'), 'x'); + await chmod(blocked, 0o000); + await quarantineUpdateCacheNamespaceForTest(cacheDirectory); + let state = await collectUpdateCacheQuarantinesForTest(cacheDirectory); + assert.equal(state.records.length, 1); + assert.equal(state.records[0].saturated, true); + await chmod(join(directory, '.cache.quarantine', `slot-${state.records[0].slot}`, 'blocked'), 0o700); + state = await collectUpdateCacheQuarantinesForTest(cacheDirectory); + assert.deepEqual(state.records, []); + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('never consumes attacker B across post-verify swap/delete/link/reparse/ABA barriers', async t => { + for (const scenario of ['swap', 'delete', 'hardlink', 'symlink', 'aba'] as const) { + await t.test(scenario, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-handoff-test-')); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + const consumed: Buffer[] = []; + let attackBlocked = false; + try { + const options = makeOptions(cacheDirectory, counted.request); + await checkForSignedUpdates(options); + const artifactPath = join( + cacheDirectory, + SIGNED_UPDATE_CACHE_POLICY.entryName, + SIGNED_UPDATE_CACHE_POLICY.artifactName, + ); + const displaced = join(directory, 'held-A'); + const attacker = join(directory, 'attacker-B'); + await writeFile(attacker, Buffer.alloc(artifact.length, 0x42), { mode: 0o600 }); + const mutate = async (): Promise => { + try { + if (scenario === 'delete') await rm(artifactPath); + else if (scenario === 'hardlink') await link(artifactPath, join(directory, 'extra-link')); + else { + await rename(artifactPath, displaced); + if (scenario === 'symlink') await symlink(attacker, artifactPath); + else await writeFile(artifactPath, Buffer.alloc(artifact.length, 0x42), { mode: 0o600 }); + } + } catch { attackBlocked = true; } + }; + const applying = applySignedUpdate({ + ...options, + applyHeldArtifact: async source => { + const split = Math.floor(artifact.length / 2); + const first = await source.read(0, split); + if (scenario === 'aba') { + await mutate(); + if (!attackBlocked) { + await rm(artifactPath); + await rename(displaced, artifactPath); + } + } + const second = await source.read(split, artifact.length - split); + consumed.push(Buffer.concat([first, second])); + }, + installVerifiedArtifact: async verified => { + if (scenario !== 'aba') await mutate(); + await verified.apply(); + }, + }); + if (attackBlocked) assert.equal(await applying, 'applied'); + else await assert.rejects(applying); + assert.deepEqual(consumed, [artifact]); + assert.equal(counted.count(), 1); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + } + }); + + test('native Windows rejects deterministic pre-CreateFileW swap, deletion, reparse, and hardlink acquisition', { + skip: process.platform !== 'win32', + }, async t => { + for (const scenario of ['swap-aba', 'delete', 'reparse', 'hardlink'] as const) { + await t.test(scenario, async () => { + const directory = await mkdtemp(join(tmpdir(), `propr-update-acquire-${scenario}-`)); + const cacheDirectory = join(directory, 'cache'); + const counted = countingFetcher(signed()); + let hookCount = 0; + let restoreCount = 0; + let signerCalls = 0; + let installerCalls = 0; + let heldReadCalls = 0; + const displaced = join(directory, 'capability-A'); + const attacker = join(directory, 'attacker-B'); + const extraLink = join(directory, 'extra-link'); + const reparseTarget = join(directory, 'reparse-target'); + try { + const options = makeOptions(cacheDirectory, counted.request); + await checkForSignedUpdates(options); + const artifactPath = join( + cacheDirectory, + SIGNED_UPDATE_CACHE_POLICY.entryName, + SIGNED_UPDATE_CACHE_POLICY.artifactName, + ); + await writeFile(attacker, Buffer.alloc(artifact.length, 0x42), { mode: 0o600 }); + await protectWindowsPrivateFile(attacker); + await mkdir(reparseTarget); + await assert.rejects(applySignedUpdate({ + ...options, + verifyNativeSigner: async () => { + signerCalls += 1; + return windowsSigner(); + }, + beforeWindowsArtifactOpenForTest: async acquiredPath => { + hookCount += 1; + assert.equal(acquiredPath, artifactPath); + if (scenario === 'hardlink') await link(artifactPath, extraLink); + else { + await rename(artifactPath, displaced); + if (scenario === 'swap-aba') await rename(attacker, artifactPath); + else if (scenario === 'reparse') { + await symlink(reparseTarget, artifactPath, 'junction'); + assert.equal( + (await lstat(artifactPath)).isSymbolicLink(), + true, + 'fixture must create a real junction reparse point', + ); + } + } + }, + ...(scenario === 'swap-aba' ? { + afterWindowsArtifactMismatchForTest: async (acquiredPath: string, acquired: { + size: string; + sha256: string; + }) => { + assert.equal(acquiredPath, artifactPath); + assert.equal(acquired.size, String(artifact.length)); + assert.equal(acquired.sha256, createHash('sha256').update(Buffer.alloc(artifact.length, 0x42)).digest('hex')); + await rename(artifactPath, attacker); + await rename(displaced, artifactPath); + assert.deepEqual(await readFile(artifactPath), artifact, 'A must be restored before caller rejection'); + restoreCount += 1; + }, + } : {}), + applyHeldArtifact: async source => { + heldReadCalls += 1; + await source.read(0, artifact.length); + }, + installVerifiedArtifact: async verified => { + installerCalls += 1; + await verified.apply(); + }, + })); + assert.equal(hookCount, 1, 'the pre-CreateFileW hook must fire exactly once'); + assert.equal(restoreCount, scenario === 'swap-aba' ? 1 : 0); + assert.equal(signerCalls, 0, 'native signer inspection must not see attacker bytes'); + assert.equal(installerCalls, 0, 'installer handoff must not receive attacker bytes'); + assert.equal(heldReadCalls, 0, 'the held-byte adapter must not read attacker bytes'); + + if (scenario === 'hardlink') await rm(extraLink, { force: true }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + } + }); + + test('cancellation removes private partials before a later safe retry', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-cache-test-')); + const cacheDirectory = join(directory, 'cache'); + const release = signed(); + const good = fetcher(release.payload, release.signature); + let cancelArtifact = true; + let artifactRequests = 0; + const request: SignedUpdateRequest = async (url, init) => { + if (url === artifactUrl) { + artifactRequests += 1; + if (cancelArtifact) throw new DOMException('cancelled', 'AbortError'); + } + return good(url, init); + }; + try { + const options = makeOptions(cacheDirectory, request); + await assert.rejects(checkForSignedUpdates(options), /cancelled/); + assert.deepEqual(await readdir(cacheDirectory), []); + cancelArtifact = false; + assert.equal(await checkForSignedUpdates(options), 'available'); + assert.equal(artifactRequests, 2); + assert.deepEqual(await readdir(cacheDirectory), [SIGNED_UPDATE_CACHE_POLICY.entryName]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe('signed update download boundary', () => { + const url = 'https://updates.example.test/update.bin'; + + test('aborts before reading a response with an oversized Content-Length', async () => { + let signal: AbortSignal | undefined; + const request: SignedUpdateRequest = async (requestedUrl, init) => { + signal = init.signal as AbortSignal; + return response(requestedUrl, [Buffer.from('ignored')], { + headers: { 'content-length': '6' }, + }); + }; + await assert.rejects( + fetchBoundedUpdateBytes({ request, url, label: 'Test metadata', maxBytes: 5, timeoutMs: 1_000 }), + /Content-Length exceeds/, + ); + assert.equal(signal?.aborted, true); + }); + + test('aborts a chunked response as soon as received bytes overflow the limit', async () => { + let signal: AbortSignal | undefined; + const request: SignedUpdateRequest = async (requestedUrl, init) => { + signal = init.signal as AbortSignal; + return response(requestedUrl, [Buffer.from('abc'), Buffer.from('def')]); + }; + await assert.rejects( + fetchBoundedUpdateBytes({ request, url, label: 'Test metadata', maxBytes: 5, timeoutMs: 1_000 }), + /received bytes exceed/, + ); + assert.equal(signal?.aborted, true); + }); + + test('aborts a stalled request at its timeout', async () => { + let signal: AbortSignal | undefined; + const request: SignedUpdateRequest = async (_requestedUrl, init) => new Promise((_resolve, reject) => { + signal = init.signal as AbortSignal; + signal.addEventListener('abort', () => reject(signal?.reason), { once: true }); + }); + await assert.rejects( + fetchBoundedUpdateBytes({ request, url, label: 'Test metadata', maxBytes: 5, timeoutMs: 10 }), + /timed out and was aborted/, + ); + assert.equal(signal?.aborted, true); + }); + + test('removes a partial artifact when a chunked response is undersized', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-boundary-test-')); + const destinationPath = join(directory, 'update.bin'); + try { + const request: SignedUpdateRequest = async requestedUrl => response(requestedUrl, [Buffer.from('four')]); + await assert.rejects( + downloadBoundedUpdateFile({ + request, + url, + destinationPath, + label: 'Test artifact', + maxBytes: 10, + timeoutMs: 1_000, + expected: { size: 5, sha256: createHash('sha256').update('wrong').digest('hex') }, + }), + /size does not match the signed size/, + ); + await assert.rejects(access(destinationPath)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('streams an exact-size artifact to one file and verifies its SHA-256', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-boundary-test-')); + const destinationPath = join(directory, 'update.bin'); + const exact = Buffer.from('exact artifact bytes'); + try { + const request: SignedUpdateRequest = async requestedUrl => response( + requestedUrl, + [exact.subarray(0, 5), exact.subarray(5)], + ); + await downloadBoundedUpdateFile({ + request, + url, + destinationPath, + label: 'Test artifact', + maxBytes: 100, + timeoutMs: 1_000, + expected: bytes(url, exact), + }); + assert.deepEqual(await readFile(destinationPath), exact); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('rejects a cross-origin final redirect URL', async () => { + const request: SignedUpdateRequest = async () => response( + 'https://cdn.example.test/update.bin', + [Buffer.from('bytes')], + ); + await assert.rejects( + fetchBoundedUpdateBytes({ request, url, label: 'Test metadata', maxBytes: 10, timeoutMs: 1_000 }), + /redirected outside its signed HTTPS origin/, + ); + }); +}); diff --git a/apps/desktop/src/signed-updates.ts b/apps/desktop/src/signed-updates.ts new file mode 100644 index 000000000..cbda20a86 --- /dev/null +++ b/apps/desktop/src/signed-updates.ts @@ -0,0 +1,1911 @@ +import { createHash, createPublicKey, randomBytes, verify, X509Certificate } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { constants as fsConstants, type BigIntStats } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + opendir, + readFile, + readdir, + rename, + rmdir, + rm, + unlink, + type FileHandle, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { parseWindowsSignerPins } from './release-config'; +import { + ensureWindowsPrivateDirectory, + inspectWindowsPrivatePath, + openWindowsLockedArtifact, + protectWindowsPrivateDirectory, + protectWindowsPrivateFile, + type WindowsFileIdentity, + type WindowsLockedArtifact, +} from './windows-update-authority'; + +export interface SignedUpdateBytes { + url: string; + size: number; + sha256: string; +} + +export interface SignedUpdateArtifact extends SignedUpdateBytes { + fileName: string; + kind: 'zip' | 'nupkg'; +} + +export interface SignedUpdateSigner { + type: 'apple-team-id' | 'authenticode-subject'; + identity: string; + designatedRequirement?: string; + certificateSha256?: string; + spkiSha256?: string; +} + +export interface SignedUpdateFeed { + target: string; + version: string; + feed: SignedUpdateBytes; + artifact: SignedUpdateArtifact; + signer: SignedUpdateSigner; +} + +export interface SignedUpdateManifest { + schemaVersion: 2; + channel: 'stable'; + manifestUrl: string; + windowsSignerPins: readonly string[]; + version: string; + tag: string; + publishedAt: string; + feeds: Record; +} + +export interface SignedUpdateRuntimeConfig { + manifestUrl: string; + publicKey: string; + signingIdentity: string; + windowsSignerPins: readonly string[]; +} + +export type SignedUpdateRequest = (url: string, init: RequestInit) => Promise; + +export const SIGNED_UPDATE_DOWNLOAD_LIMITS = { + manifestBytes: 512 * 1024, + signatureBytes: 1024, + feedBytes: 1024 * 1024, + // Desktop packages should remain far below this; the cap bounds disk use even for signed misconfiguration. + artifactBytes: 1024 * 1024 * 1024, + metadataTimeoutMs: 30_000, + artifactTimeoutMs: 10 * 60_000, + squirrelReleaseBytes: 64 * 1024, +} as const; + +export const SIGNED_UPDATE_CACHE_POLICY = { + expiryMs: 10 * 60_000, + metadataBytes: 16 * 1024, + entryName: 'verified-update', + artifactName: 'artifact', + metadataName: 'entry.json', + lockName: '.cache-lock', + lockOwnerName: 'owner.json', + // The namespace contains one signed artifact and its small metadata record only. + namespaceBytes: 1024 * 1024 * 1024 + 64 * 1024, + maxRootEntries: 2, + maxEntryEntries: 2, + inspectionEntryCap: 64, + inspectionNameBytes: 16 * 1024, + inspectionDepth: 3, + inspectionElapsedMs: 250, + cleanupEntryCap: 64, + cleanupByteCap: 128 * 1024 * 1024, + quarantineSlots: 4, + quarantineGlobalNames: 256, + quarantineGlobalBytes: 4 * 1024 * 1024 * 1024, + quarantineMaxAgeMs: 7 * 24 * 60 * 60_000, + quarantineStateBytes: 4096, +} as const; + +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const SHA1_PATTERN = /^[a-fA-F0-9]{40}$/; +const TARGET_PATTERN = /^(darwin|win32)-(x64|arm64)$/; +const SQUIRREL_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}\.nupkg$/; +const execFileAsync = promisify(execFile); +const cacheLocks = new Map>(); + +export interface SquirrelReleaseEntry { + sha1: string; + fileName: string; + size: number; +} + +export interface VerifiedUpdateArtifact { + feedBytes: Buffer; + artifact: SignedUpdateArtifact; + /** One-shot application of the still-held, exact verified byte capability. */ + apply(): Promise; +} + +export interface HeldUpdateArtifactSource { + readonly artifact: SignedUpdateArtifact; + readonly feedBytes: Buffer; + read(offset: number, length: number): Promise; +} + +interface ExpectedDownloadBytes { + size: number; + sha256: string; +} + +interface BoundedDownloadOptions { + request: SignedUpdateRequest; + url: string; + label: string; + maxBytes: number; + timeoutMs: number; + expected?: ExpectedDownloadBytes; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const parseHttpsUrl = ( + value: unknown, + label: string, + { allowQuery = true }: { allowQuery?: boolean } = {}, +): string => { + if (typeof value !== 'string') throw new Error(`${label} must be a string`); + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be an absolute HTTPS URL`); + } + if (url.protocol !== 'https:' || url.username || url.password || url.hash || (!allowQuery && url.search)) { + throw new Error(`${label} must be an HTTPS URL without credentials, a fragment${allowQuery ? '' : ', or a query'}`); + } + return url.toString(); +}; + +const parseBytes = (value: unknown, label: string): SignedUpdateBytes => { + if (!isRecord(value)) throw new Error(`${label} is invalid`); + if (!Number.isSafeInteger(value.size) || Number(value.size) <= 0) { + throw new Error(`${label} size is invalid`); + } + if (typeof value.sha256 !== 'string' || !SHA256_PATTERN.test(value.sha256)) { + throw new Error(`${label} SHA-256 is invalid`); + } + return { + url: parseHttpsUrl(value.url, `${label} URL`), + size: Number(value.size), + sha256: value.sha256, + }; +}; + +const parseFeed = (value: unknown, target: string, version: string): SignedUpdateFeed => { + const label = `Signed update manifest feed ${target}`; + if (!isRecord(value) || value.target !== target || value.version !== version) { + throw new Error(`${label} does not bind its exact target and version`); + } + const feed = parseBytes(value.feed, `${label} metadata`); + const parsedArtifact = parseBytes(value.artifact, `${label} artifact`); + if (feed.size > SIGNED_UPDATE_DOWNLOAD_LIMITS.feedBytes) { + throw new Error(`${label} metadata exceeds the runtime download limit`); + } + if (parsedArtifact.size > SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes) { + throw new Error(`${label} artifact exceeds the runtime download limit`); + } + if (!isRecord(value.artifact) + || typeof value.artifact.fileName !== 'string' + || basename(value.artifact.fileName) !== value.artifact.fileName + || (value.artifact.kind !== 'zip' && value.artifact.kind !== 'nupkg')) { + throw new Error(`${label} artifact descriptor is invalid`); + } + const expectedKind = target.startsWith('darwin-') ? 'zip' : 'nupkg'; + const [, arch] = target.split('-'); + const expectedFileName = target.startsWith('darwin-') + ? `ProPR-Desktop-${version}-macos-${arch}-zip` + : `ProPR-Desktop-${version}-windows-${arch}-full.nupkg`; + if (value.artifact.kind !== expectedKind + || value.artifact.fileName !== expectedFileName + || basename(new URL(parsedArtifact.url).pathname) !== value.artifact.fileName) { + throw new Error(`${label} artifact does not match its target or URL`); + } + const expectedSignerType = target.startsWith('darwin-') ? 'apple-team-id' : 'authenticode-subject'; + if (!isRecord(value.signer) + || value.signer.type !== expectedSignerType + || typeof value.signer.identity !== 'string' + || !value.signer.identity.trim()) { + throw new Error(`${label} native signer is invalid`); + } + if (expectedSignerType === 'apple-team-id' + && (typeof value.signer.designatedRequirement !== 'string' || !value.signer.designatedRequirement.trim())) { + throw new Error(`${label} macOS designated requirement is invalid`); + } + if (expectedSignerType === 'authenticode-subject' + && (typeof value.signer.certificateSha256 !== 'string' + || !SHA256_PATTERN.test(value.signer.certificateSha256) + || typeof value.signer.spkiSha256 !== 'string' + || !SHA256_PATTERN.test(value.signer.spkiSha256))) { + throw new Error(`${label} Windows signer fingerprint evidence is invalid`); + } + return { + target, + version, + feed, + artifact: { + ...parsedArtifact, + fileName: value.artifact.fileName, + kind: value.artifact.kind, + }, + signer: { + type: value.signer.type as SignedUpdateSigner['type'], + identity: value.signer.identity, + ...(expectedSignerType === 'apple-team-id' + ? { designatedRequirement: value.signer.designatedRequirement as string } + : { + certificateSha256: value.signer.certificateSha256 as string, + spkiSha256: value.signer.spkiSha256 as string, + }), + }, + }; +}; + +export const parseSignedUpdateManifest = (payload: Buffer): SignedUpdateManifest => { + let value: unknown; + try { + value = JSON.parse(payload.toString('utf8')); + } catch { + throw new Error('Signed update manifest is not valid JSON'); + } + if (!isRecord(value) || value.schemaVersion !== 2 || value.channel !== 'stable') { + throw new Error('Signed update manifest has an unsupported schema or channel'); + } + if (typeof value.version !== 'string' || !VERSION_PATTERN.test(value.version)) { + throw new Error('Signed update manifest version is not canonical stable semver'); + } + const manifestUrl = parseHttpsUrl( + value.manifestUrl, + 'Signed update manifest URL', + { allowQuery: false }, + ); + if (value.tag !== `desktop-v${value.version}`) { + throw new Error('Signed update manifest tag does not match its version'); + } + if (typeof value.publishedAt !== 'string' || !Number.isFinite(Date.parse(value.publishedAt))) { + throw new Error('Signed update manifest publishedAt is invalid'); + } + if (!Array.isArray(value.windowsSignerPins) + || value.windowsSignerPins.some(pin => typeof pin !== 'string')) { + throw new Error('Signed update manifest Windows signer pin policy is invalid'); + } + const windowsSignerPins = parseWindowsSignerPins( + (value.windowsSignerPins as string[]).join(','), + 'Signed update manifest Windows signer pin policy', + ); + if (!isRecord(value.feeds)) throw new Error('Signed update manifest feeds are missing'); + + const feeds: Record = {}; + for (const [target, candidate] of Object.entries(value.feeds)) { + if (!TARGET_PATTERN.test(target)) throw new Error(`Signed update manifest feed ${target} is invalid`); + feeds[target] = parseFeed(candidate, target, value.version); + } + return { ...value, manifestUrl, windowsSignerPins, feeds } as unknown as SignedUpdateManifest; +}; + +export const verifySignedUpdateManifest = ( + payload: Buffer, + signatureBase64: string, + publicKeyBase64: string, +): SignedUpdateManifest => { + let publicKey; + try { + publicKey = createPublicKey({ + key: Buffer.from(publicKeyBase64, 'base64'), + format: 'der', + type: 'spki', + }); + } catch { + throw new Error('Embedded update verification key is invalid'); + } + if (publicKey.asymmetricKeyType !== 'ed25519') { + throw new Error('Embedded update verification key is not Ed25519'); + } + const signature = Buffer.from(signatureBase64.trim(), 'base64'); + if (signature.length !== 64 || !verify(null, payload, publicKey, signature)) { + throw new Error('Signed update manifest signature verification failed'); + } + return parseSignedUpdateManifest(payload); +}; + +const compareVersions = (left: string, right: string): number => { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index]; + } + return 0; +}; + +const verifyBytes = (bytes: Buffer, expected: SignedUpdateBytes, label: string): void => { + if (bytes.length !== expected.size) throw new Error(`${label} size does not match the signed manifest`); + const actualHash = createHash('sha256').update(bytes).digest('hex'); + if (actualHash !== expected.sha256) throw new Error(`${label} SHA-256 does not match the signed manifest`); +}; + +const responseContentLength = (response: Response, label: string): number | undefined => { + const header = response.headers.get('content-length'); + if (header === null) return undefined; + if (!/^(0|[1-9]\d*)$/.test(header)) throw new Error(`${label} has an invalid Content-Length header`); + const length = Number(header); + if (!Number.isSafeInteger(length)) throw new Error(`${label} has an invalid Content-Length header`); + return length; +}; + +const validateDownloadResponse = ( + requestedUrl: string, + response: Response, + label: string, + maxBytes: number, + expected?: ExpectedDownloadBytes, +): void => { + const requested = new URL(requestedUrl); + let finalUrl: URL; + try { + finalUrl = new URL(response.url); + } catch { + throw new Error(`${label} response has no valid final URL`); + } + if (finalUrl.protocol !== 'https:' || finalUrl.username || finalUrl.password || finalUrl.origin !== requested.origin) { + throw new Error(`${label} response redirected outside its signed HTTPS origin`); + } + + const contentLength = responseContentLength(response, label); + if (contentLength !== undefined && contentLength > maxBytes) { + throw new Error(`${label} Content-Length exceeds the runtime download limit`); + } + if (contentLength !== undefined && expected && contentLength !== expected.size) { + throw new Error(`${label} Content-Length does not match the signed size`); + } + if (!response.ok) throw new Error(`${label} request failed with HTTP ${response.status}`); +}; + +const withBoundedResponse = async ( + options: BoundedDownloadOptions, + consume: (response: Response, signal: AbortSignal) => Promise, +): Promise => { + const { request, url, label, maxBytes, timeoutMs, expected } = options; + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + let response: Response | undefined; + try { + response = await request(url, { + cache: 'no-store', + credentials: 'omit', + redirect: 'follow', + signal: controller.signal, + }); + validateDownloadResponse(url, response, label, maxBytes, expected); + return await consume(response, controller.signal); + } catch (error) { + controller.abort(); + if (response?.body && !response.body.locked) await response.body.cancel().catch(() => undefined); + if (timedOut) throw new Error(`${label} request timed out and was aborted`); + throw error; + } finally { + clearTimeout(timeout); + } +}; + +const consumeResponse = async ( + response: Response, + signal: AbortSignal, + { label, maxBytes, expected }: Pick, + consumeChunk: (chunk: Uint8Array) => Promise | void, +): Promise => { + if (!response.body) { + if (expected?.size) throw new Error(`${label} size does not match the signed size`); + return; + } + + const reader = response.body.getReader(); + const hash = expected ? createHash('sha256') : undefined; + let received = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (signal.aborted) throw signal.reason; + if (!value?.byteLength) continue; + received += value.byteLength; + if (received > maxBytes || (expected && received > expected.size)) { + await reader.cancel().catch(() => undefined); + throw new Error(`${label} received bytes exceed the runtime download limit`); + } + hash?.update(value); + await consumeChunk(value); + } + } finally { + reader.releaseLock(); + } + + if (expected && received !== expected.size) throw new Error(`${label} size does not match the signed size`); + if (expected && hash?.digest('hex') !== expected.sha256) { + throw new Error(`${label} SHA-256 does not match the signed manifest`); + } +}; + +export const fetchBoundedUpdateBytes = async (options: BoundedDownloadOptions): Promise => { + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) { + throw new Error(`${options.label} runtime download limit is invalid`); + } + if (options.expected && options.expected.size > options.maxBytes) { + throw new Error(`${options.label} signed size exceeds the runtime download limit`); + } + + return withBoundedResponse(options, async (response, signal) => { + const bytes = Buffer.alloc(options.expected?.size ?? options.maxBytes); + let offset = 0; + await consumeResponse(response, signal, options, chunk => { + Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).copy(bytes, offset); + offset += chunk.byteLength; + }); + return bytes.subarray(0, offset); + }); +}; + +export const downloadBoundedUpdateFile = async ( + options: BoundedDownloadOptions & { destinationPath: string; expected: ExpectedDownloadBytes }, +): Promise => { + if (options.expected.size > options.maxBytes) { + throw new Error(`${options.label} signed size exceeds the runtime download limit`); + } + + let file; + try { + file = await open( + options.destinationPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + // On Windows the protected DACL must exist before any response bytes are written. + await file.close(); + file = undefined; + await protectPrivateFile(options.destinationPath); + file = await open(options.destinationPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + await withBoundedResponse(options, async (response, signal) => { + await consumeResponse(response, signal, options, async chunk => { + const bytes = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + let offset = 0; + while (offset < bytes.length) { + const { bytesWritten } = await file!.write(bytes, offset, bytes.length - offset); + offset += bytesWritten; + } + }); + }); + await file.sync(); + await file.close(); + file = undefined; + } catch (error) { + await file?.close().catch(() => undefined); + await rm(options.destinationPath, { force: true }); + throw error; + } +}; + +export const parseSquirrelReleaseEntry = ( + feedBytes: Buffer, + version: string, + artifact: SignedUpdateArtifact, +): SquirrelReleaseEntry => { + const fail = (): never => { throw new Error('Signed Windows update feed is invalid'); }; + const canonicalFileNames = new Set([ + `ProPR-Desktop-${version}-windows-x64-full.nupkg`, + `ProPR-Desktop-${version}-windows-arm64-full.nupkg`, + ]); + if (!VERSION_PATTERN.test(version) + || artifact.kind !== 'nupkg' + || !canonicalFileNames.has(artifact.fileName) + || feedBytes.length === 0 + || feedBytes.length > SIGNED_UPDATE_DOWNLOAD_LIMITS.squirrelReleaseBytes) fail(); + + let text: string; + try { text = new TextDecoder('utf-8', { fatal: true }).decode(feedBytes); } catch { return fail(); } + if (text.includes('\0') || text.includes('\r') && !text.includes('\r\n')) fail(); + const normalized = text.endsWith('\r\n') + ? text.slice(0, -2) + : text.endsWith('\n') ? text.slice(0, -1) : text; + if (!normalized || normalized.includes('\r') && !normalized.split('\r\n').every(Boolean)) fail(); + const lines = normalized.split(text.includes('\r\n') ? '\r\n' : '\n'); + if (lines.length > 128 || lines.some(line => !line || line.length > 512)) fail(); + + const seen = new Set(); + const selected: SquirrelReleaseEntry[] = []; + for (const line of lines) { + const tokens = line.split(' '); + if (tokens.length !== 3 || tokens.some(token => !token)) fail(); + const [sha1, fileName, sizeText] = tokens; + if (!SHA1_PATTERN.test(sha1) + || !SQUIRREL_FILE_NAME_PATTERN.test(fileName) + || basename(fileName) !== fileName + || fileName.includes('/') + || fileName.includes('\\') + || !/^[1-9]\d*$/.test(sizeText)) fail(); + const size = Number(sizeText); + if (!Number.isSafeInteger(size) || size > SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes) fail(); + const foldedName = fileName.toLowerCase(); + if (seen.has(foldedName)) fail(); + seen.add(foldedName); + if (fileName === artifact.fileName) selected.push({ sha1: sha1.toLowerCase(), fileName, size }); + } + if (selected.length !== 1 || selected[0].size !== artifact.size) fail(); + return selected[0]; +}; + +const verifyFeedReferencesArtifact = ( + target: string, + version: string, + feedBytes: Buffer, + artifact: SignedUpdateArtifact, +): SquirrelReleaseEntry | undefined => { + if (target.startsWith('darwin-')) { + let feed: unknown; + try { + feed = JSON.parse(feedBytes.toString('utf8')); + } catch { + throw new Error('Signed macOS update feed is not valid JSON'); + } + if (!isRecord(feed) || feed.url !== artifact.url || feed.name !== version) { + throw new Error('Signed macOS update feed does not reference the bound version and artifact URL'); + } + return undefined; + } + return parseSquirrelReleaseEntry(feedBytes, version, artifact); +}; + +export const validateMacOSUpdateApplicationLayout = async (extracted: string): Promise => { + const application = join(extracted, 'propr-desktop.app'); + let applicationStats; + try { + applicationStats = await lstat(application); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('macOS update ZIP is missing the canonical propr-desktop.app bundle'); + } + throw error; + } + if (!applicationStats.isDirectory() || applicationStats.isSymbolicLink()) { + throw new Error('macOS update ZIP canonical propr-desktop.app bundle must be a real directory'); + } + + const topLevel = await readdir(extracted); + if (topLevel.length !== 1 || topLevel[0] !== 'propr-desktop.app') { + throw new Error('macOS update ZIP has an ambiguous application layout'); + } + return application; +}; + +export const verifyNativeUpdateSigner = async ( + packagePath: string, + artifact: SignedUpdateArtifact, + expected: SignedUpdateSigner, +): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-check-')); + try { + const extracted = join(directory, 'extracted'); + if (expected.type === 'apple-team-id') { + await execFileAsync('/usr/bin/ditto', ['-x', '-k', packagePath, extracted]); + const application = await validateMacOSUpdateApplicationLayout(extracted); + await execFileAsync('/usr/bin/codesign', ['--verify', '--deep', '--strict', application]); + await execFileAsync('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', application]); + const details = await execFileAsync('/usr/bin/codesign', ['-d', '--verbose=4', application]); + const output = `${details.stdout}\n${details.stderr}`; + const identity = /^TeamIdentifier=(.+)$/m.exec(output)?.[1]?.trim(); + if (!identity) throw new Error('macOS update has no designated Team ID'); + const requirement = await execFileAsync('/usr/bin/codesign', ['-d', '-r-', application]); + const designatedRequirement = `${requirement.stdout}\n${requirement.stderr}` + .split(/\r?\n/) + .map(line => line.trim()) + .find(line => line.startsWith('designated =>')); + if (!designatedRequirement) throw new Error('macOS update has no designated requirement'); + return { type: 'apple-team-id', identity, designatedRequirement }; + } + + const script = [ + '$ErrorActionPreference = "Stop"', + `$package = ${JSON.stringify(packagePath)}`, + `$extract = ${JSON.stringify(extracted)}`, + '$zip = "$package.zip"', + 'Copy-Item -LiteralPath $package -Destination $zip', + 'Expand-Archive -LiteralPath $zip -DestinationPath $extract', + "$executable = Get-Item -LiteralPath (Join-Path $extract 'lib/net45/propr-desktop.exe')", + "if (!$executable -or $executable.PSIsContainer) { throw 'Windows update package canonical application is missing' }", + '$signature = Get-AuthenticodeSignature -LiteralPath $executable.FullName', + "if ($signature.Status -ne 'Valid' -or !$signature.SignerCertificate -or !$signature.TimeStamperCertificate) { throw 'Windows update Authenticode chain or timestamp status is invalid' }", + '$certificateBase64 = [Convert]::ToBase64String($signature.SignerCertificate.RawData)', + '@{ identity = $signature.SignerCertificate.Subject; certificateBase64 = $certificateBase64 } | ConvertTo-Json -Compress', + ].join('; '); + const { stdout } = await execFileAsync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script]); + let evidence: { identity?: string; certificateBase64?: string }; + try { evidence = JSON.parse(stdout.trim()); } catch { throw new Error('Windows update signer evidence is invalid'); } + if (!evidence.identity || !evidence.certificateBase64) { + throw new Error('Windows update has incomplete Authenticode signer evidence'); + } + let certificate: X509Certificate; + try { certificate = new X509Certificate(Buffer.from(evidence.certificateBase64, 'base64')); } catch { + throw new Error('Windows update signer certificate evidence is invalid'); + } + return { + type: 'authenticode-subject', + identity: evidence.identity, + certificateSha256: certificate.fingerprint256.replaceAll(':', '').toLowerCase(), + spkiSha256: createHash('sha256').update(certificate.publicKey.export({ format: 'der', type: 'spki' })).digest('hex'), + }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; + +interface UpdateCacheKey { + origin: string; + channel: 'stable'; + version: string; + manifestSha256: string; + artifactSha256: string; + target: string; + artifactSize: number; + artifactFileName: string; +} + +interface UpdateCacheMetadata { + schemaVersion: 1; + createdAt: number; + expiresAt: number; + key: UpdateCacheKey; +} + +interface SignedUpdateOperationOptions { + config: SignedUpdateRuntimeConfig; + currentVersion: string; + platform: NodeJS.Platform; + arch: string; + request: SignedUpdateRequest; + cacheDirectory?: string; + now?: () => number; + verifyNativeSigner?: ( + packagePath: string, + artifact: SignedUpdateArtifact, + signer: SignedUpdateSigner, + ) => Promise; + /** Platform adapter that consumes only held bytes; mutable path adapters are intentionally unsupported. */ + applyHeldArtifact?: (source: HeldUpdateArtifactSource) => Promise; + /** Native-test-only deterministic barrier immediately before the broker's CreateFileW. */ + beforeWindowsArtifactOpenForTest?: (packagePath: string) => Promise; + /** Native-test-only restoration point after a mismatched handle has been closed but before rejection. */ + afterWindowsArtifactMismatchForTest?: ( + packagePath: string, + acquired: Readonly<{ identity: WindowsFileIdentity; size: string; sha256: string }>, + ) => Promise; +} + +interface PreparedSignedUpdate { + manifest: SignedUpdateManifest; + manifestDigest: string; + target: string; + feed: SignedUpdateFeed; + feedBytes: Buffer; + squirrelEntry?: SquirrelReleaseEntry; +} + +const acquireFilesystemCacheLock = async (cacheDirectory: string): Promise<() => Promise> => { + await collectQuarantines(cacheDirectory); + if (process.platform !== 'win32') await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + await ensurePrivateDirectory(cacheDirectory); + await preflightCacheNamespace(cacheDirectory); + const lockPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.lockName); + const ownerPath = join(lockPath, SIGNED_UPDATE_CACHE_POLICY.lockOwnerName); + const deadline = Date.now() + SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactTimeoutMs + 30_000; + while (true) { + try { + await mkdir(lockPath, { mode: 0o700 }); + if (process.platform === 'win32') await protectWindowsPrivateDirectory(lockPath); + else { + await chmod(lockPath, 0o700); + await inspectPrivatePath(lockPath, true); + } + let owner = await open( + ownerPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await owner.close(); + await protectPrivateFile(ownerPath); + owner = await open(ownerPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + await owner.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); + await owner.sync(); + await owner.close(); + const ownerInspection = process.platform === 'win32' ? await inspectPrivatePath(ownerPath) : undefined; + const ownerBytes = ownerInspection ? Number(ownerInspection.size) : 0; + const windowsLock = process.platform === 'win32' && ownerInspection && Number.isSafeInteger(ownerBytes) + && ownerBytes > 0 + ? await openWindowsLockedArtifact( + ownerPath, + ownerBytes, + undefined, + undefined, + ownerInspection.identity as WindowsFileIdentity, + ) + : undefined; + if (process.platform === 'win32' && !windowsLock) throw new Error('Verified update cache lock is unavailable'); + return async () => { + await windowsLock?.close(); + await removeCachePath(lockPath); + await syncDirectory(cacheDirectory); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + let active = false; + try { + const heldOwner = await openPrivateRegularFile(ownerPath, 1024); + let bytes: Buffer; + try { + const size = heldOwner.windowsLock + ? BigInt(heldOwner.windowsLock.inspection.size) + : (await heldOwner.handle!.stat({ bigint: true })).size; + if (size <= 0n || size > 1024n) throw new Error('Verified update cache lock is unavailable'); + bytes = await readHeldFile(heldOwner, 0, Number(size)); + } finally { + try { await heldOwner.windowsLock?.close(); } finally { await heldOwner.handle?.close(); } + } + const value: unknown = JSON.parse(bytes.toString('utf8')); + if (isRecord(value) && value.schemaVersion === 1 && Number.isSafeInteger(value.pid) && Number(value.pid) > 0) { + try { process.kill(Number(value.pid), 0); active = true; } catch { active = false; } + } + } catch { active = false; } + if (!active) { + const stalePath = join(cacheDirectory, `.stale-lock-${randomBytes(8).toString('hex')}`); + let removed = false; + try { + await rename(lockPath, stalePath); + await removeCachePath(stalePath); + removed = true; + } catch { /* A live owner may have won the inspection race; retry without trusting it. */ } + if (!removed) { + if (Date.now() >= deadline) throw new Error('Verified update cache lock is unavailable'); + await new Promise(resolve => setTimeout(resolve, 100)); + } + continue; + } + if (Date.now() >= deadline) throw new Error('Verified update cache lock is unavailable'); + await new Promise(resolve => setTimeout(resolve, 100)); + } + } +}; + +const withCacheLock = async ( + cacheDirectory: string, + operation: (cacheLockHeld: boolean) => Promise, +): Promise => { + const previous = cacheLocks.get(cacheDirectory) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise(resolve => { release = resolve; }); + const queued = previous.then(() => current); + cacheLocks.set(cacheDirectory, queued); + await previous; + let releaseFilesystemLock: (() => Promise) | undefined; + try { + try { releaseFilesystemLock = await acquireFilesystemCacheLock(cacheDirectory); } catch { /* cache use will fail closed */ } + return await operation(releaseFilesystemLock !== undefined); + } finally { + try { await releaseFilesystemLock?.(); } finally { + release(); + if (cacheLocks.get(cacheDirectory) === queued) cacheLocks.delete(cacheDirectory); + } + } +}; + +interface PosixFileIdentity { + platform: 'posix'; + device: string; + inode: string; +} + +type ExactFileIdentity = PosixFileIdentity | WindowsFileIdentity; + +export const canonicalPosixFileIdentity = (device: bigint, inode: bigint): PosixFileIdentity => ({ + platform: 'posix', + device: device.toString(10), + inode: inode.toString(10), +}); + +export const sameExactFileIdentity = (left: ExactFileIdentity, right: ExactFileIdentity): boolean => + left.platform === right.platform && (left.platform === 'win32' + ? left.volumeSerial === (right as WindowsFileIdentity).volumeSerial + && left.fileId128 === (right as WindowsFileIdentity).fileId128 + : left.device === (right as PosixFileIdentity).device + && left.inode === (right as PosixFileIdentity).inode); + +export const posixAuthorityIsPrivate = (owner: bigint, mode: bigint, currentUid?: bigint): boolean => + currentUid !== undefined && owner === currentUid && (mode & 0o077n) === 0n; + +const isOwnedPrivate = (stats: BigIntStats, directory = false): boolean => { + const expectedType = directory ? stats.isDirectory() : stats.isFile(); + const currentUid = typeof process.getuid === 'function' ? BigInt(process.getuid()) : undefined; + return expectedType && !stats.isSymbolicLink() && posixAuthorityIsPrivate(stats.uid, stats.mode, currentUid); +}; + +const inspectPrivatePath = async ( + path: string, + directory = false, +): Promise<{ identity: ExactFileIdentity; size: bigint; links: bigint }> => { + if (process.platform === 'win32') { + const inspected = await inspectWindowsPrivatePath(path, directory); + return { identity: inspected.identity, size: BigInt(inspected.size), links: BigInt(inspected.links) }; + } + const stats = await lstat(path, { bigint: true }); + if (!isOwnedPrivate(stats, directory) || (!directory && stats.nlink !== 1n)) { + throw new Error('Verified update cache authority inspection failed'); + } + return { + identity: canonicalPosixFileIdentity(stats.dev, stats.ino), + size: stats.size, + links: stats.nlink, + }; +}; + +const ensurePrivateDirectory = async (path: string): Promise => { + if (process.platform === 'win32') { + await ensureWindowsPrivateDirectory(path); + return; + } + try { + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + await chmod(path, 0o700); + await inspectPrivatePath(path, true); +}; + +const protectPrivateFile = async (path: string): Promise => { + if (process.platform === 'win32') { + await protectWindowsPrivateFile(path); + return; + } + await chmod(path, 0o600); + await inspectPrivatePath(path); +}; + +const syncDirectory = async (path: string): Promise => { + let handle: FileHandle | undefined; + try { + handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + await handle.sync(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (process.platform !== 'win32' || !['EINVAL', 'ENOTSUP', 'EPERM', 'EISDIR'].includes(code ?? '')) throw error; + } finally { + await handle?.close(); + } +}; + +interface NamespaceBudget { + entries: number; + nameBytes: number; + bytes: number; + readonly startedAt: number; + readonly entryCap: number; + readonly byteCap: number; +} + +const newNamespaceBudget = ( + entryCap = SIGNED_UPDATE_CACHE_POLICY.inspectionEntryCap, + byteCap = Number.MAX_SAFE_INTEGER, +): NamespaceBudget => ({ + entries: 0, + nameBytes: 0, + bytes: 0, + startedAt: Date.now(), + entryCap, + byteCap, +}); + +const assertNamespaceBudget = (budget: NamespaceBudget, name?: string): void => { + if (Date.now() - budget.startedAt > SIGNED_UPDATE_CACHE_POLICY.inspectionElapsedMs + || budget.entries >= budget.entryCap) throw new Error('Verified update cache namespace inspection limit exceeded'); + if (name !== undefined) { + const bytes = Buffer.byteLength(name); + if (bytes <= 0 || bytes > SIGNED_UPDATE_CACHE_POLICY.inspectionNameBytes + || budget.nameBytes + bytes > SIGNED_UPDATE_CACHE_POLICY.inspectionNameBytes) { + throw new Error('Verified update cache namespace inspection limit exceeded'); + } + budget.entries += 1; + budget.nameBytes += bytes; + } +}; + +const boundedDirectoryNames = async (path: string, budget = newNamespaceBudget()): Promise => { + const directory = await opendir(path); + const names: string[] = []; + try { + while (true) { + assertNamespaceBudget(budget); + const entry = await directory.read(); + if (!entry) break; + assertNamespaceBudget(budget, entry.name); + names.push(entry.name); + } + } finally { + try { await directory.close(); } catch { /* async iteration may already have closed it */ } + } + return names; +}; + +const boundedRemoveCachePath = async ( + path: string, + budget = newNamespaceBudget(SIGNED_UPDATE_CACHE_POLICY.cleanupEntryCap), + depth = 0, +): Promise => { + if (depth > SIGNED_UPDATE_CACHE_POLICY.inspectionDepth) return false; + let stats; + try { stats = await lstat(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; + throw error; + } + if (!stats.isDirectory() || stats.isSymbolicLink()) { + assertNamespaceBudget(budget); + if (budget.bytes > 0 && budget.bytes + stats.size > budget.byteCap) return false; + budget.entries += 1; + budget.bytes += stats.size; + await unlink(path); + return true; + } + const directory = await opendir(path); + let complete = true; + try { + while (true) { + try { assertNamespaceBudget(budget); } catch { complete = false; break; } + const entry = await directory.read(); + if (!entry) break; + try { assertNamespaceBudget(budget, entry.name); } catch { complete = false; break; } + if (depth === SIGNED_UPDATE_CACHE_POLICY.inspectionDepth + || !await boundedRemoveCachePath(join(path, entry.name), budget, depth + 1)) { + complete = false; + break; + } + } + } finally { + try { await directory.close(); } catch { /* already closed */ } + } + if (!complete) return false; + try { await rmdir(path); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return false; + } + return true; +}; + +const removeCachePath = async (path: string): Promise => { + if (!await boundedRemoveCachePath(path)) { + throw new Error('Verified update cache bounded cleanup limit exceeded'); + } +}; + +interface QuarantineRecord { + slot: number; + createdAt: number; + names: number; + bytes: number; + saturated: boolean; +} + +interface QuarantineState { + schemaVersion: 1; + cursor: number; + records: QuarantineRecord[]; +} + +const quarantineRootFor = (cacheDirectory: string): string => + join(dirname(cacheDirectory), `.${basename(cacheDirectory)}.quarantine`); + +const quarantineSlotPath = (root: string, slot: number): string => join(root, `slot-${slot}`); + +const ensureQuarantineRoot = async (cacheDirectory: string): Promise => { + const root = quarantineRootFor(cacheDirectory); + if (process.platform !== 'win32') { + try { await mkdir(root, { mode: 0o700 }); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + } else await ensureWindowsPrivateDirectory(root); + await inspectPrivatePath(root, true); + return root; +}; + +const validQuarantineRecord = (value: unknown): value is QuarantineRecord => isRecord(value) + && Number.isInteger(value.slot) && Number(value.slot) >= 0 + && Number(value.slot) < SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + && Number.isSafeInteger(value.createdAt) && Number(value.createdAt) >= 0 + && Number.isSafeInteger(value.names) && Number(value.names) >= 0 + && Number.isSafeInteger(value.bytes) && Number(value.bytes) >= 0 + && typeof value.saturated === 'boolean' + && Object.keys(value).length === 5; + +const readQuarantineState = async (root: string): Promise => { + const statePath = join(root, 'collector.json'); + let value: unknown = { schemaVersion: 1, cursor: 0, records: [] }; + try { + const inspected = await inspectPrivatePath(statePath); + if (inspected.size <= 0n || inspected.size > BigInt(SIGNED_UPDATE_CACHE_POLICY.quarantineStateBytes)) throw new Error('invalid'); + value = JSON.parse(await readFile(statePath, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + // A missing state record is recoverable from the fixed slot namespace; + // malformed or broad metadata is never trusted. + try { await lstat(statePath); } catch (statError) { + if ((statError as NodeJS.ErrnoException).code === 'ENOENT') return value as QuarantineState; + } + throw new Error('Verified update quarantine metadata is invalid'); + } + } + if (!isRecord(value) || value.schemaVersion !== 1 + || !Number.isInteger(value.cursor) || Number(value.cursor) < 0 + || Number(value.cursor) >= SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + || !Array.isArray(value.records) || value.records.length > SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + || !value.records.every(validQuarantineRecord) + || new Set(value.records.map(record => record.slot)).size !== value.records.length + || Object.keys(value).length !== 3) { + throw new Error('Verified update quarantine metadata is invalid'); + } + return value as unknown as QuarantineState; +}; + +const writeQuarantineState = async (root: string, state: QuarantineState): Promise => { + const statePath = join(root, 'collector.json'); + const temporary = join(root, 'collector.next'); + const bytes = Buffer.from(`${JSON.stringify(state)}\n`); + if (bytes.length > SIGNED_UPDATE_CACHE_POLICY.quarantineStateBytes) { + throw new Error('Verified update quarantine metadata is invalid'); + } + await rm(temporary, { force: true }); + let handle = await open( + temporary, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await handle.close(); + await protectPrivateFile(temporary); + handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); } + await rename(temporary, statePath); + await syncDirectory(root); +}; + +const collectQuarantines = async (cacheDirectory: string): Promise<{ root: string; state: QuarantineState }> => { + const root = await ensureQuarantineRoot(cacheDirectory); + const state = await readQuarantineState(root); + const records = new Map(state.records.map(record => [record.slot, record])); + // Fixed slots avoid an attacker-controlled parent-directory walk. Missing + // metadata is reconstructed conservatively and marks the backlog saturated. + for (let slot = 0; slot < SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; slot += 1) { + try { + await lstat(quarantineSlotPath(root, slot)); + if (!records.has(slot)) records.set(slot, { slot, createdAt: 0, names: 0, bytes: 0, saturated: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + records.delete(slot); + } + } + const budget = newNamespaceBudget( + SIGNED_UPDATE_CACHE_POLICY.cleanupEntryCap, + SIGNED_UPDATE_CACHE_POLICY.cleanupByteCap, + ); + for (let count = 0; count < SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; count += 1) { + const slot = (state.cursor + count) % SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; + const record = records.get(slot); + if (!record) continue; + const entriesBefore = budget.entries; + const bytesBefore = budget.bytes; + let complete = false; + try { complete = await boundedRemoveCachePath(quarantineSlotPath(root, slot), budget); } catch { complete = false; } + record.names += budget.entries - entriesBefore; + record.bytes += budget.bytes - bytesBefore; + record.saturated = !complete; + if (complete) records.delete(slot); + state.cursor = (slot + 1) % SIGNED_UPDATE_CACHE_POLICY.quarantineSlots; + if (!complete) break; + } + state.records = [...records.values()].sort((left, right) => left.slot - right.slot); + await writeQuarantineState(root, state); + return { root, state }; +}; + +const quarantineCacheNamespace = async (cacheDirectory: string): Promise => { + const { root, state } = await collectQuarantines(cacheDirectory); + const now = Date.now(); + const globalNames = state.records.reduce((total, record) => total + record.names, 0); + const globalBytes = state.records.reduce((total, record) => total + record.bytes, 0); + if (state.records.some(record => record.saturated || now - record.createdAt > SIGNED_UPDATE_CACHE_POLICY.quarantineMaxAgeMs) + || state.records.length >= SIGNED_UPDATE_CACHE_POLICY.quarantineSlots + || globalNames >= SIGNED_UPDATE_CACHE_POLICY.quarantineGlobalNames + || globalBytes >= SIGNED_UPDATE_CACHE_POLICY.quarantineGlobalBytes) { + throw new Error('Verified update quarantine backlog exceeds the global bound'); + } + const occupied = new Set(state.records.map(record => record.slot)); + const slot = Array.from({ length: SIGNED_UPDATE_CACHE_POLICY.quarantineSlots }, (_, index) => index) + .find(candidate => !occupied.has(candidate)); + if (slot === undefined) throw new Error('Verified update quarantine backlog exceeds the global bound'); + const quarantine = quarantineSlotPath(root, slot); + try { + await rename(cacheDirectory, quarantine); + } catch { + throw new Error('Verified update cache namespace could not be quarantined'); + } + try { + await ensurePrivateDirectory(cacheDirectory); + } catch (error) { + try { await rename(quarantine, cacheDirectory); } catch { /* preserve quarantine if a concurrent creator won */ } + throw error; + } + state.records.push({ slot, createdAt: now, names: 0, bytes: 0, saturated: false }); + state.records.sort((left, right) => left.slot - right.slot); + await writeQuarantineState(root, state); + // One bounded pass makes small quarantines disappear immediately. Oversized + // trees resume from their mutated filesystem cursor on later launches. + await collectQuarantines(cacheDirectory); +}; + +/** Native-test-only bounded collector probe; returns fixed non-secret progress metadata. */ +export const collectUpdateCacheQuarantinesForTest = async (cacheDirectory: string): Promise> => { + const { state } = await collectQuarantines(cacheDirectory); + return Object.freeze({ + schemaVersion: 1, + cursor: state.cursor, + records: state.records.map(record => Object.freeze({ ...record })), + }); +}; + +/** Native-test-only invalid-namespace transition into the fixed quarantine slots. */ +export const quarantineUpdateCacheNamespaceForTest = quarantineCacheNamespace; + +const preflightCacheNamespace = async (cacheDirectory: string): Promise => { + const budget = newNamespaceBudget(); + let invalid = false; + try { + const names = await boundedDirectoryNames(cacheDirectory, budget); + const folded = new Set(); + if (names.length > SIGNED_UPDATE_CACHE_POLICY.maxRootEntries) invalid = true; + for (const name of names) { + const canonical = name.toLocaleLowerCase('en-US'); + if (folded.has(canonical)) invalid = true; + folded.add(canonical); + if (name !== SIGNED_UPDATE_CACHE_POLICY.entryName && name !== SIGNED_UPDATE_CACHE_POLICY.lockName) { + invalid = true; + break; + } + const child = join(cacheDirectory, name); + await inspectPrivatePath(child, true); + const childNames = await boundedDirectoryNames(child, budget); + const expected: Set = name === SIGNED_UPDATE_CACHE_POLICY.entryName + ? new Set([SIGNED_UPDATE_CACHE_POLICY.artifactName, SIGNED_UPDATE_CACHE_POLICY.metadataName]) + : new Set([SIGNED_UPDATE_CACHE_POLICY.lockOwnerName]); + if (childNames.length !== expected.size) invalid = true; + let childBytes = 0n; + for (const childName of childNames) { + if (!expected.delete(childName)) invalid = true; + const inspected = await inspectPrivatePath(join(child, childName)); + childBytes += inspected.size; + } + if (name === SIGNED_UPDATE_CACHE_POLICY.lockName && (childBytes <= 0n || childBytes > 1024n)) invalid = true; + if (name === SIGNED_UPDATE_CACHE_POLICY.entryName + && childBytes > BigInt(SIGNED_UPDATE_CACHE_POLICY.namespaceBytes)) invalid = true; + if (expected.size !== 0) invalid = true; + } + } catch { + invalid = true; + } + if (invalid) await quarantineCacheNamespace(cacheDirectory); +}; + +const prepareCacheDirectory = async (cacheDirectory: string, now: number): Promise => { + await collectQuarantines(cacheDirectory); + if (process.platform !== 'win32') await mkdir(cacheDirectory, { recursive: true, mode: 0o700 }); + await ensurePrivateDirectory(cacheDirectory); + + const names = await boundedDirectoryNames(cacheDirectory); + const foldedNames = new Set(); + let invalidateEntry = names.length > SIGNED_UPDATE_CACHE_POLICY.maxRootEntries; + for (const name of names) { + const folded = name.toLocaleLowerCase('en-US'); + if (foldedNames.has(folded)) invalidateEntry = true; + foldedNames.add(folded); + if (name === SIGNED_UPDATE_CACHE_POLICY.lockName) { + await inspectPrivatePath(join(cacheDirectory, name), true); + const lockNames = await boundedDirectoryNames(join(cacheDirectory, name)); + if (lockNames.length !== 1 || lockNames[0] !== SIGNED_UPDATE_CACHE_POLICY.lockOwnerName) { + throw new Error('Verified update cache is unavailable'); + } + const owner = await inspectPrivatePath(join(cacheDirectory, name, lockNames[0])); + if (owner.size <= 0n || owner.size > 1024n) throw new Error('Verified update cache is unavailable'); + continue; + } + if (name.startsWith('.partial-') || name !== SIGNED_UPDATE_CACHE_POLICY.entryName) { + throw new Error('Verified update cache contains unknown content'); + } + } + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + try { + if (invalidateEntry) throw new Error('invalid'); + await inspectPrivatePath(entryPath, true); + const entryNames = await boundedDirectoryNames(entryPath); + if (entryNames.length !== SIGNED_UPDATE_CACHE_POLICY.maxEntryEntries) throw new Error('invalid'); + const expected = new Set([SIGNED_UPDATE_CACHE_POLICY.artifactName, SIGNED_UPDATE_CACHE_POLICY.metadataName]); + const foldedEntryNames = new Set(); + let totalBytes = 0n; + for (const name of entryNames) { + const folded = name.toLocaleLowerCase('en-US'); + if (foldedEntryNames.has(folded) || !expected.delete(name)) throw new Error('invalid'); + foldedEntryNames.add(folded); + const inspected = await inspectPrivatePath(join(entryPath, name)); + if (inspected.links !== 1n) throw new Error('invalid'); + totalBytes += inspected.size; + } + if (expected.size !== 0 || totalBytes > BigInt(SIGNED_UPDATE_CACHE_POLICY.namespaceBytes)) { + throw new Error('invalid'); + } + const metadata = await readCacheMetadata(entryPath); + if (metadata.expiresAt <= now) await removeCachePath(entryPath); + } catch { + await removeCachePath(entryPath); + } +}; + +interface HeldPrivateFile { + handle?: FileHandle; + identity: ExactFileIdentity; + path: string; + windowsLock?: WindowsLockedArtifact; +} + +const openPrivateRegularFile = async ( + path: string, + maxBytes = SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + expectedBeforeAcquisition?: { identity: ExactFileIdentity; size: bigint; sha256?: string }, + beforeWindowsOpenForTest?: () => Promise, + afterWindowsMismatchForTest?: ( + acquired: Readonly<{ identity: WindowsFileIdentity; size: string; sha256: string }>, + ) => Promise, +): Promise => { + if (process.platform === 'win32') { + const setup = expectedBeforeAcquisition ?? await inspectPrivatePath(path); + const exactBytes = Number(setup.size); + if (!Number.isSafeInteger(exactBytes) || exactBytes <= 0 || exactBytes > maxBytes + || setup.identity.platform !== 'win32') throw new Error('Verified update artifact is invalid'); + const windowsLock = await openWindowsLockedArtifact( + path, + exactBytes, + beforeWindowsOpenForTest, + undefined, + setup.identity, + expectedBeforeAcquisition?.sha256, + ); + if (expectedBeforeAcquisition + && (!sameExactFileIdentity(windowsLock.inspection.identity, expectedBeforeAcquisition.identity) + || BigInt(windowsLock.inspection.size) !== expectedBeforeAcquisition.size + || expectedBeforeAcquisition.sha256 !== undefined + && windowsLock.inspection.sha256 !== expectedBeforeAcquisition.sha256)) { + const acquired = Object.freeze({ + identity: windowsLock.inspection.identity, + size: windowsLock.inspection.size, + sha256: windowsLock.inspection.sha256, + }); + await windowsLock.close(); + await afterWindowsMismatchForTest?.(acquired); + throw new Error('Verified update artifact acquisition changed [update-acquire:capability-mismatch]'); + } + return { + identity: windowsLock.inspection.identity, + path, + windowsLock, + }; + } + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const stats = await handle.stat({ bigint: true }); + const inspected = await inspectPrivatePath(path); + const pathStats = await lstat(path, { bigint: true }); + if (stats.nlink !== 1n || pathStats.nlink !== 1n + || pathStats.dev !== stats.dev || pathStats.ino !== stats.ino || pathStats.size !== stats.size + || inspected.size !== stats.size || inspected.links !== 1n + || !isOwnedPrivate(stats)) { + throw new Error('Verified update cache entry is invalid'); + } + return { handle, identity: inspected.identity, path }; + } catch (error) { + await handle.close(); + throw error; + } +}; + +const readHeldFile = async (held: HeldPrivateFile, offset: number, length: number): Promise => { + if (held.windowsLock) return held.windowsLock.read(offset, length); + if (!held.handle) throw new Error('Verified update artifact capability is unavailable'); + const bytes = Buffer.alloc(length); + const { bytesRead } = await held.handle.read(bytes, 0, length, offset); + if (bytesRead !== length) throw new Error('Verified update artifact capability is unavailable'); + return bytes; +}; + +const hashHeldFile = async (held: HeldPrivateFile, maxBytes: number): Promise<{ size: number; sha256: string; sha1: string }> => { + if (held.windowsLock) { + const verified = await held.windowsLock.verify(); + const size = Number(verified.size); + if (!Number.isSafeInteger(size) || size <= 0 || size > maxBytes) { + throw new Error('Verified update artifact is invalid'); + } + return { size, sha256: verified.sha256, sha1: verified.sha1 }; + } + if (!held.handle) throw new Error('Verified update artifact is invalid'); + const handle = held.handle; + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile() || stats.nlink !== 1n || stats.size <= 0n || stats.size > BigInt(maxBytes)) { + throw new Error('Verified update artifact is invalid'); + } + const sha256 = createHash('sha256'); + const sha1 = createHash('sha1'); + const size = Number(stats.size); + const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, size)); + let offset = 0; + while (offset < size) { + const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, size - offset), offset); + if (bytesRead === 0) throw new Error('Verified update artifact is invalid'); + const bytes = chunk.subarray(0, bytesRead); + sha256.update(bytes); + sha1.update(bytes); + offset += bytesRead; + } + return { size: offset, sha256: sha256.digest('hex'), sha1: sha1.digest('hex') }; +}; + +const assertHeldArtifact = async ( + held: HeldPrivateFile, + path: string, + artifact: SignedUpdateArtifact, + squirrelEntry?: SquirrelReleaseEntry, +): Promise => { + if (held.windowsLock) { + const verified = await held.windowsLock.verify(); + if (!sameExactFileIdentity(verified.identity, held.identity) + || verified.links !== '1' + || BigInt(verified.size) !== BigInt(artifact.size)) { + throw new Error('Verified update artifact is invalid'); + } + if (Number(verified.size) !== artifact.size || verified.sha256 !== artifact.sha256) { + throw new Error('Verified update artifact does not match signed metadata'); + } + if (squirrelEntry && (Number(verified.size) !== squirrelEntry.size || verified.sha1 !== squirrelEntry.sha1)) { + throw new Error('Verified update artifact does not match Squirrel metadata'); + } + return; + } + if (!held.handle) throw new Error('Verified update artifact is invalid'); + const descriptor = await held.handle.stat({ bigint: true }); + const pathStats = await lstat(path, { bigint: true }); + const inspected = await inspectPrivatePath(path); + if (descriptor.nlink !== 1n || pathStats.nlink !== 1n + || pathStats.dev !== descriptor.dev || pathStats.ino !== descriptor.ino || pathStats.size !== descriptor.size + || inspected.size !== descriptor.size || !sameExactFileIdentity(inspected.identity, held.identity) + || process.platform !== 'win32' && !isOwnedPrivate(descriptor)) { + throw new Error('Verified update artifact is invalid'); + } + const hashes = await hashHeldFile(held, SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes); + if (hashes.size !== artifact.size || hashes.sha256 !== artifact.sha256) { + throw new Error('Verified update artifact does not match signed metadata'); + } + // SHA-1 is only Squirrel's compatibility binding; signed SHA-256 metadata remains the trust root. + if (squirrelEntry && (hashes.size !== squirrelEntry.size || hashes.sha1 !== squirrelEntry.sha1)) { + throw new Error('Verified update artifact does not match Squirrel metadata'); + } +}; + +const assertSigner = (actual: SignedUpdateSigner, expected: SignedUpdateSigner): void => { + if (actual.type !== expected.type + || actual.identity !== expected.identity + || actual.designatedRequirement !== expected.designatedRequirement + || actual.certificateSha256 !== expected.certificateSha256 + || actual.spkiSha256 !== expected.spkiSha256) { + throw new Error('Native update artifact signer does not match the signed build pin'); + } +}; + +const verifyHeldNativeSigner = async ( + source: HeldPrivateFile, + prepared: PreparedSignedUpdate, + verifyNativeSigner: NonNullable, +): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-signer-snapshot-')); + let snapshot: HeldPrivateFile | undefined; + try { + if (process.platform === 'win32') await protectWindowsPrivateDirectory(directory); + else { + await chmod(directory, 0o700); + await inspectPrivatePath(directory, true); + } + const snapshotPath = join(directory, prepared.feed.artifact.fileName); + let output = await open( + snapshotPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await output.close(); + await protectPrivateFile(snapshotPath); + output = await open(snapshotPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + try { + const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, prepared.feed.artifact.size)); + let offset = 0; + while (offset < prepared.feed.artifact.size) { + const length = Math.min(chunk.length, prepared.feed.artifact.size - offset); + const bytes = await readHeldFile(source, offset, length); + bytes.copy(chunk, 0); + const bytesRead = bytes.length; + let written = 0; + while (written < bytesRead) { + const result = await output.write(chunk, written, bytesRead - written, offset + written); + if (result.bytesWritten === 0) throw new Error('Verified update signer snapshot is invalid'); + written += result.bytesWritten; + } + offset += bytesRead; + } + await output.sync(); + } finally { + await output.close(); + } + snapshot = await openPrivateRegularFile(snapshotPath); + await assertHeldArtifact(snapshot, snapshotPath, prepared.feed.artifact, prepared.squirrelEntry); + const beforeSignerDirectory = await lstat(directory, { bigint: true }); + const signer = await verifyNativeSigner(snapshotPath, prepared.feed.artifact, prepared.feed.signer); + const afterSignerDirectory = await lstat(directory, { bigint: true }); + if (beforeSignerDirectory.dev !== afterSignerDirectory.dev + || beforeSignerDirectory.ino !== afterSignerDirectory.ino + || beforeSignerDirectory.ctimeNs !== afterSignerDirectory.ctimeNs + || beforeSignerDirectory.mtimeNs !== afterSignerDirectory.mtimeNs) { + throw new Error('Verified update signer snapshot is invalid'); + } + await assertHeldArtifact(snapshot, snapshotPath, prepared.feed.artifact, prepared.squirrelEntry); + return signer; + } finally { + try { await snapshot?.windowsLock?.close(); } finally { + await snapshot?.handle?.close(); + await rm(directory, { recursive: true, force: true }); + } + } +}; + +const withVerifiedArtifact = async ( + packagePath: string, + prepared: PreparedSignedUpdate, + verifyNativeSigner: NonNullable, + use: (held: HeldPrivateFile) => Promise, + beforeWindowsOpenForTest?: SignedUpdateOperationOptions['beforeWindowsArtifactOpenForTest'], + afterWindowsMismatchForTest?: SignedUpdateOperationOptions['afterWindowsArtifactMismatchForTest'], +): Promise => { + // A pathname capability is captured before the broker's first artifact open. + // The native test barrier runs inside the broker launch protocol immediately + // before CreateFileW; the returned full identity/size/hash must still bind A. + const expectedBeforeAcquisition = { + ...await inspectPrivatePath(packagePath), + sha256: prepared.feed.artifact.sha256, + }; + const held = await openPrivateRegularFile( + packagePath, + SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + expectedBeforeAcquisition, + beforeWindowsOpenForTest ? () => beforeWindowsOpenForTest(packagePath) : undefined, + afterWindowsMismatchForTest + ? acquired => afterWindowsMismatchForTest(packagePath, acquired) + : undefined, + ); + try { + const entryDirectory = dirname(packagePath); + const cacheDirectory = dirname(entryDirectory); + const initialDirectory = await inspectPrivatePath(entryDirectory, true); + const initialParent = await inspectPrivatePath(cacheDirectory, true); + const initialDirectoryState = process.platform === 'win32' ? undefined : await lstat(entryDirectory, { bigint: true }); + const initialParentState = process.platform === 'win32' ? undefined : await lstat(cacheDirectory, { bigint: true }); + const assertDirectoryUnchanged = async (): Promise => { + const current = await inspectPrivatePath(entryDirectory, true); + const currentParent = await inspectPrivatePath(cacheDirectory, true); + const currentDirectoryState = initialDirectoryState && await lstat(entryDirectory, { bigint: true }); + const currentParentState = initialParentState && await lstat(cacheDirectory, { bigint: true }); + if (!sameExactFileIdentity(current.identity, initialDirectory.identity) + || !sameExactFileIdentity(currentParent.identity, initialParent.identity) + || initialDirectoryState && currentDirectoryState + && (currentDirectoryState.ctimeNs !== initialDirectoryState.ctimeNs + || currentDirectoryState.mtimeNs !== initialDirectoryState.mtimeNs) + || initialParentState && currentParentState + && (currentParentState.ctimeNs !== initialParentState.ctimeNs + || currentParentState.mtimeNs !== initialParentState.mtimeNs)) { + throw new Error('Verified update artifact is invalid'); + } + }; + await assertHeldArtifact(held, packagePath, prepared.feed.artifact, prepared.squirrelEntry); + assertSigner( + await verifyHeldNativeSigner(held, prepared, verifyNativeSigner), + prepared.feed.signer, + ); + await assertDirectoryUnchanged(); + await assertHeldArtifact(held, packagePath, prepared.feed.artifact, prepared.squirrelEntry); + const result = await use(held); + await assertDirectoryUnchanged(); + await assertHeldArtifact(held, packagePath, prepared.feed.artifact, prepared.squirrelEntry); + return result; + } finally { + try { await held.windowsLock?.close(); } finally { await held.handle?.close(); } + } +}; + +const readCacheMetadata = async (entryPath: string): Promise => { + const path = join(entryPath, SIGNED_UPDATE_CACHE_POLICY.metadataName); + const held = await openPrivateRegularFile(path, SIGNED_UPDATE_CACHE_POLICY.metadataBytes); + try { + const size = held.windowsLock + ? BigInt(held.windowsLock.inspection.size) + : (await held.handle!.stat({ bigint: true })).size; + if (size <= 0n || size > BigInt(SIGNED_UPDATE_CACHE_POLICY.metadataBytes)) { + throw new Error('Verified update cache entry is invalid'); + } + const bytes = await readHeldFile(held, 0, Number(size)); + const value: unknown = JSON.parse(bytes.toString('utf8')); + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.key) + || !Number.isSafeInteger(value.createdAt) || !Number.isSafeInteger(value.expiresAt)) { + throw new Error('Verified update cache entry is invalid'); + } + return value as unknown as UpdateCacheMetadata; + } catch { + throw new Error('Verified update cache entry is invalid'); + } finally { + try { await held.windowsLock?.close(); } finally { await held.handle?.close(); } + } +}; + +const exactCacheKey = (left: UpdateCacheKey, right: UpdateCacheKey): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const cacheKeyFor = (prepared: PreparedSignedUpdate): UpdateCacheKey => ({ + origin: new URL(prepared.manifest.manifestUrl).origin, + channel: prepared.manifest.channel, + version: prepared.manifest.version, + manifestSha256: prepared.manifestDigest, + artifactSha256: prepared.feed.artifact.sha256, + target: prepared.target, + artifactSize: prepared.feed.artifact.size, + artifactFileName: prepared.feed.artifact.fileName, +}); + +const findCachedArtifact = async ( + cacheDirectory: string, + key: UpdateCacheKey, + now: number, +): Promise => { + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + try { + await inspectPrivatePath(entryPath, true); + const metadata = await readCacheMetadata(entryPath); + if (metadata.expiresAt <= now || metadata.expiresAt - metadata.createdAt !== SIGNED_UPDATE_CACHE_POLICY.expiryMs + || !exactCacheKey(metadata.key, key)) throw new Error('invalid'); + return join(entryPath, SIGNED_UPDATE_CACHE_POLICY.artifactName); + } catch { + await removeCachePath(entryPath); + return undefined; + } +}; + +const publishCachedArtifact = async ( + cacheDirectory: string, + prepared: PreparedSignedUpdate, + request: SignedUpdateRequest, + verifyNativeSigner: NonNullable, + now: number, +): Promise => { + const partialName = `.partial-${randomBytes(16).toString('hex')}`; + const partialPath = join(cacheDirectory, partialName); + const artifactPath = join(partialPath, SIGNED_UPDATE_CACHE_POLICY.artifactName); + await ensurePrivateDirectory(partialPath); + try { + await downloadBoundedUpdateFile({ + request, + url: prepared.feed.artifact.url, + destinationPath: artifactPath, + label: 'Native update artifact', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactTimeoutMs, + expected: prepared.feed.artifact, + }); + await protectPrivateFile(artifactPath); + await withVerifiedArtifact(artifactPath, prepared, verifyNativeSigner, async () => undefined); + + const metadata: UpdateCacheMetadata = { + schemaVersion: 1, + createdAt: now, + expiresAt: now + SIGNED_UPDATE_CACHE_POLICY.expiryMs, + key: cacheKeyFor(prepared), + }; + const metadataPath = join(partialPath, SIGNED_UPDATE_CACHE_POLICY.metadataName); + let metadataHandle = await open( + metadataPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600, + ); + await metadataHandle.close(); + await protectPrivateFile(metadataPath); + metadataHandle = await open(metadataPath, fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW); + try { + await metadataHandle.writeFile(`${JSON.stringify(metadata)}\n`, 'utf8'); + await metadataHandle.sync(); + } finally { + await metadataHandle.close(); + } + await syncDirectory(partialPath); + + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + await removeCachePath(entryPath); + await rename(partialPath, entryPath); + await syncDirectory(cacheDirectory); + return join(entryPath, SIGNED_UPDATE_CACHE_POLICY.artifactName); + } catch (error) { + await removeCachePath(partialPath); + throw error; + } +}; + +const prepareSignedUpdate = async ({ + config, + currentVersion, + platform, + arch, + request, +}: SignedUpdateOperationOptions): Promise => { + if (platform !== 'darwin' && platform !== 'win32') return 'unsupported'; + if (!VERSION_PATTERN.test(currentVersion)) throw new Error('Current desktop version is invalid'); + + const manifestUrl = parseHttpsUrl( + config.manifestUrl, + 'Embedded update manifest URL', + { allowQuery: false }, + ); + const [payload, signature] = await Promise.all([ + fetchBoundedUpdateBytes({ + request, + url: manifestUrl, + label: 'Signed update manifest', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.manifestBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.metadataTimeoutMs, + }), + fetchBoundedUpdateBytes({ + request, + url: `${manifestUrl}.sig`, + label: 'Signed update manifest signature', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.signatureBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.metadataTimeoutMs, + }), + ]); + const manifest = verifySignedUpdateManifest(payload, signature.toString('ascii'), config.publicKey); + if (manifest.manifestUrl !== manifestUrl) { + throw new Error('Signed update manifest does not bind the embedded manifest URL'); + } + if (compareVersions(manifest.version, currentVersion) <= 0) return 'current'; + + const target = `${platform}-${arch}`; + const feed = manifest.feeds[target]; + if (!feed) throw new Error(`Signed update manifest does not contain a feed for ${target}`); + if (feed.target !== target || feed.version !== manifest.version) { + throw new Error('Signed update feed target or version does not match the requested update'); + } + if (feed.signer.identity !== config.signingIdentity) { + throw new Error('Signed update native signer does not match the identity embedded in this build'); + } + if (platform === 'win32') { + if (!Array.isArray(config.windowsSignerPins)) throw new Error('Embedded Windows signer pin allowlist is invalid'); + const configuredPins = parseWindowsSignerPins(config.windowsSignerPins.join(','), 'Embedded Windows signer pin allowlist'); + if (JSON.stringify(manifest.windowsSignerPins) !== JSON.stringify(configuredPins)) { + throw new Error('Signed update Windows signer pin policy does not match the signed application policy'); + } + const evidencePins = new Set([ + `certificate-sha256:${feed.signer.certificateSha256}`, + `spki-sha256:${feed.signer.spkiSha256}`, + ]); + if (!configuredPins.some(pin => evidencePins.has(pin))) { + throw new Error('Signed update Windows signer fingerprint is not in the embedded allowlist'); + } + } + + const feedBytes = await fetchBoundedUpdateBytes({ + request, + url: feed.feed.url, + label: 'Native update feed', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.feedBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.metadataTimeoutMs, + expected: feed.feed, + }); + verifyBytes(feedBytes, feed.feed, 'Native update feed'); + const squirrelEntry = verifyFeedReferencesArtifact(target, manifest.version, feedBytes, feed.artifact); + return { + manifest, + manifestDigest: createHash('sha256').update(payload).digest('hex'), + target, + feed, + feedBytes, + squirrelEntry, + }; +}; + +const usePreparedArtifact = async ( + prepared: PreparedSignedUpdate, + options: SignedUpdateOperationOptions, + consume: boolean, + use: (held: HeldPrivateFile) => Promise, +): Promise => { + const verifySigner = options.verifyNativeSigner ?? verifyNativeUpdateSigner; + let cacheDirectory = options.cacheDirectory; + const now = (options.now ?? Date.now)(); + if (cacheDirectory) { + try { + await prepareCacheDirectory(cacheDirectory, now); + } catch { + // Cache authority is never availability: authenticate a fresh private download instead. + cacheDirectory = undefined; + } + } + if (!cacheDirectory) { + const directory = await mkdtemp(join(tmpdir(), 'propr-update-download-')); + try { + if (process.platform === 'win32') await protectWindowsPrivateDirectory(directory); + else { + await chmod(directory, 0o700); + await inspectPrivatePath(directory, true); + } + const heldDirectory = join(directory, 'held'); + await ensurePrivateDirectory(heldDirectory); + const packagePath = join(heldDirectory, prepared.feed.artifact.fileName); + await downloadBoundedUpdateFile({ + request: options.request, + url: prepared.feed.artifact.url, + destinationPath: packagePath, + label: 'Native update artifact', + maxBytes: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactBytes, + timeoutMs: SIGNED_UPDATE_DOWNLOAD_LIMITS.artifactTimeoutMs, + expected: prepared.feed.artifact, + }); + await protectPrivateFile(packagePath); + return await withVerifiedArtifact( + packagePath, + prepared, + verifySigner, + use, + options.beforeWindowsArtifactOpenForTest, + options.afterWindowsArtifactMismatchForTest, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + } + + const entryPath = join(cacheDirectory, SIGNED_UPDATE_CACHE_POLICY.entryName); + const key = cacheKeyFor(prepared); + let packagePath = await findCachedArtifact(cacheDirectory, key, now); + if (packagePath) { + let useStarted = false; + try { + const result = await withVerifiedArtifact(packagePath, prepared, verifySigner, held => { + useStarted = true; + return use(held); + }, options.beforeWindowsArtifactOpenForTest, options.afterWindowsArtifactMismatchForTest); + if (consume) await removeCachePath(entryPath); + return result; + } catch (error) { + await removeCachePath(entryPath); + if (useStarted || options.beforeWindowsArtifactOpenForTest) throw error; + packagePath = undefined; + } + } + + packagePath = await publishCachedArtifact( + cacheDirectory, + prepared, + options.request, + verifySigner, + now, + ); + try { + return await withVerifiedArtifact( + packagePath, + prepared, + verifySigner, + use, + options.beforeWindowsArtifactOpenForTest, + options.afterWindowsArtifactMismatchForTest, + ); + } finally { + if (consume) await removeCachePath(entryPath); + } +}; + +export const checkForSignedUpdates = async ( + options: SignedUpdateOperationOptions, +): Promise<'available' | 'current' | 'unsupported'> => { + const operation = async (cacheLockHeld = true): Promise<'available' | 'current' | 'unsupported'> => { + const effectiveOptions = cacheLockHeld ? options : { ...options, cacheDirectory: undefined }; + const prepared = await prepareSignedUpdate(effectiveOptions); + if (prepared === 'current' || prepared === 'unsupported') return prepared; + await usePreparedArtifact(prepared, effectiveOptions, false, async () => undefined); + return 'available'; + }; + return options.cacheDirectory ? withCacheLock(options.cacheDirectory, operation) : operation(); +}; + +export const applySignedUpdate = async ( + options: SignedUpdateOperationOptions & { + installVerifiedArtifact: (artifact: VerifiedUpdateArtifact) => Promise; + }, +): Promise<'applied' | 'current' | 'unsupported'> => { + const operation = async (cacheLockHeld = true): Promise<'applied' | 'current' | 'unsupported'> => { + const effectiveOptions = cacheLockHeld ? options : { ...options, cacheDirectory: undefined }; + const prepared = await prepareSignedUpdate(effectiveOptions); + if (prepared === 'current' || prepared === 'unsupported') return prepared; + if (!effectiveOptions.applyHeldArtifact) { + throw new Error('Automatic update apply is unavailable for a held verified artifact'); + } + await usePreparedArtifact(prepared, effectiveOptions, true, async held => { + let active = true; + let application: Promise | undefined; + const source: HeldUpdateArtifactSource = Object.freeze({ + artifact: prepared.feed.artifact, + feedBytes: Buffer.from(prepared.feedBytes), + read: async (offset: number, length: number): Promise => { + if (!active || !Number.isSafeInteger(offset) || offset < 0 + || !Number.isSafeInteger(length) || length <= 0 || length > 1024 * 1024 + || offset + length > prepared.feed.artifact.size) { + throw new Error('Verified update artifact capability is unavailable'); + } + return readHeldFile(held, offset, length); + }, + }); + const capability: VerifiedUpdateArtifact = Object.freeze({ + feedBytes: Buffer.from(prepared.feedBytes), + artifact: Object.freeze({ ...prepared.feed.artifact }), + apply: async (): Promise => { + if (!active || application) throw new Error('Verified update artifact capability is unavailable'); + application = (async () => { + // The challenge proves that the exact broker session is live at the + // launch barrier. Its no-share handle remains held while the platform + // adapter consumes only source.read(), never a mutable pathname. + await held.windowsLock?.verify(); + await effectiveOptions.applyHeldArtifact!(source); + await held.windowsLock?.verify(); + })(); + await application; + }, + }); + try { + await effectiveOptions.installVerifiedArtifact(capability); + if (!application) throw new Error('Verified update artifact capability was not consumed'); + await application; + } finally { + active = false; + } + }); + return 'applied'; + }; + return options.cacheDirectory ? withCacheLock(options.cacheDirectory, operation) : operation(); +}; diff --git a/apps/desktop/src/squirrel-events.test.ts b/apps/desktop/src/squirrel-events.test.ts new file mode 100644 index 000000000..78f4e5666 --- /dev/null +++ b/apps/desktop/src/squirrel-events.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { handleSquirrelStartupEvent, squirrelAppUserModelId } from './squirrel-events'; + +describe('Squirrel.Windows startup events', () => { + test('binds the package AUMID to the hyphenated executable name', () => { + assert.equal(squirrelAppUserModelId('propr-desktop'), 'com.squirrel.propr_desktop.propr-desktop'); + }); + + test('creates shortcuts and schedules a clean exit after install', () => { + const calls: unknown[] = []; + const handled = handleSquirrelStartupEvent({ + argv: ['app.exe', '--squirrel-install'], + execPath: '/tmp/ProPR/app-1.2.3/propr-desktop.exe', + quit: () => calls.push('quit'), + spawnUpdate: (command, args) => calls.push({ command, args }), + schedule: (callback, delay) => { calls.push({ delay }); callback(); }, + }); + assert.equal(handled, true); + assert.deepEqual(calls.at(-2), { delay: 1_000 }); + assert.equal(calls.at(-1), 'quit'); + assert.deepEqual((calls[0] as { args: string[] }).args, ['--createShortcut', 'propr-desktop.exe']); + }); + + test('does not consume first-run or unrelated arguments', () => { + const quit = () => assert.fail('must not quit'); + assert.equal(handleSquirrelStartupEvent({ argv: ['app.exe', '--squirrel-firstrun'], quit }), false); + assert.equal(handleSquirrelStartupEvent({ argv: ['app.exe', 'propr://open'], quit }), false); + }); +}); diff --git a/apps/desktop/src/squirrel-events.ts b/apps/desktop/src/squirrel-events.ts new file mode 100644 index 000000000..d96739572 --- /dev/null +++ b/apps/desktop/src/squirrel-events.ts @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process'; +import { basename, dirname, resolve } from 'node:path'; + +type SpawnUpdate = (command: string, args: string[]) => void; + +export const DESKTOP_EXECUTABLE_NAME = 'propr-desktop'; +export const SQUIRREL_PACKAGE_NAME = 'propr_desktop'; + +export const squirrelAppUserModelId = ( + executableName = DESKTOP_EXECUTABLE_NAME, +): string => `com.squirrel.${SQUIRREL_PACKAGE_NAME}.${executableName}`; + +const defaultSpawnUpdate: SpawnUpdate = (command, args) => { + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.unref(); +}; + +export const handleSquirrelStartupEvent = ({ + argv = process.argv, + execPath = process.execPath, + quit, + spawnUpdate = defaultSpawnUpdate, + schedule = setTimeout, +}: { + argv?: readonly string[]; + execPath?: string; + quit: () => void; + spawnUpdate?: SpawnUpdate; + schedule?: (callback: () => void, delay: number) => unknown; +}): boolean => { + const event = argv[1]; + if (!event?.startsWith('--squirrel-')) return false; + + const executableName = basename(execPath); + const updateExecutable = resolve(dirname(execPath), '..', 'Update.exe'); + switch (event) { + case '--squirrel-install': + case '--squirrel-updated': + spawnUpdate(updateExecutable, ['--createShortcut', executableName]); + schedule(quit, 1_000); + return true; + case '--squirrel-uninstall': + spawnUpdate(updateExecutable, ['--removeShortcut', executableName]); + schedule(quit, 1_000); + return true; + case '--squirrel-obsolete': + quit(); + return true; + case '--squirrel-firstrun': + return false; + default: + // Unknown Squirrel flags must not suppress normal startup. + return false; + } +}; diff --git a/apps/desktop/src/windows-update-authority.test.ts b/apps/desktop/src/windows-update-authority.test.ts new file mode 100644 index 000000000..811b69fa4 --- /dev/null +++ b/apps/desktop/src/windows-update-authority.test.ts @@ -0,0 +1,1058 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash, X509Certificate } from 'node:crypto'; +import { copyFile, link, lstat, mkdir, mkdtemp, readFile, rename, rm, symlink, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { + crashWindowsLockedArtifactForTest, + authenticateWindowsAuthorityHelperForTest, + decodeWindowsAuthorityFramesForTest, + encodeWindowsAuthorityFrameForTest, + inspectWindowsAuthorityHelperPeForTest, + ensureWindowsPrivateDirectory, + injectWindowsAuthorityHeldFaultForTest, + injectWindowsAuthorityProtocolFaultForTest, + injectWindowsAuthorityTransportFaultForTest, + inspectWindowsPrivatePath, + openWindowsLockedArtifact, + parseWindowsAuthorityStartupFailureForTest, + parseWindowsAuthorityHelperManifestForTest, + probeWindowsAuthorityCompile, + probeWindowsAuthorityCompileFailureForTest, + probeWindowsAuthorityBootstrapStageForTest, + probeWindowsAuthorityProcessImageMismatchForTest, + probeWindowsAuthorityNativeBoundaryForTest, + probeWindowsAuthorityStartupFailureForTest, + protectWindowsPrivateFile, + shutdownWindowsAuthorityBrokerForTest, + smokeWindowsUpdateAuthority, + validateBootstrapIdentityRecordForTest, + windowsAuthorityBrokerStatsForTest, + WINDOWS_AUTHORITY_COMPILE_STAGES, +} from './windows-update-authority'; +import { + prepareWindowsAuthorityBuildDirectory, + sealWindowsAuthorityDirectory, +} from '../scripts/build-windows-native-launcher.mjs'; + +const execFileAsync = promisify(execFile); +const windowsOnly = { skip: process.platform !== 'win32' }; +const kernelPowerShell = String.raw`\\?\GLOBALROOT\SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe`; +const kernelIcacls = String.raw`\\?\GLOBALROOT\SystemRoot\System32\icacls.exe`; +const compilerInputEvidence = (name: string, sha256: string) => ({ + name, + size: 1, + sha256, + signerCertificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + signerSpkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + signerRootSpkiSha256: '3'.repeat(64), + catalogName: 'Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat', + catalogSha256: 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef', + catalogVolumeSerial: '5'.repeat(16), + catalogFileId128: '6'.repeat(32), +}); + +test('native Windows exact production C# compile probe reaches ready', windowsOnly, async () => { + assert.equal(await probeWindowsAuthorityCompile(), 'READY'); +}); + +test('native Windows compile probe bounds startup failure to an enumerated non-secret stage', windowsOnly, async () => { + assert.equal(await probeWindowsAuthorityCompileFailureForTest(), 'BUILD_OUTPUT'); + assert.equal(await probeWindowsAuthorityStartupFailureForTest(), 'ready_protocol'); +}); + +const helperManifest = (overrides: Record = {}): Buffer => Buffer.from(`${JSON.stringify({ + schemaVersion: 1, + name: 'propr-windows-authority.exe', + format: 'PE32', + architecture: 'anycpu', + machine: 'I386', + clr: true, + size: 4096, + sha256: 'a'.repeat(64), + sourceSha256: 'b'.repeat(64), + protocol: 'propr-windows-authority-v1', + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + launcher: { + name: 'propr-windows-launcher.node', + format: 'PE', + architecture: 'x64', + machine: 'AMD64', + size: 4096, + sha256: 'f'.repeat(64), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + bootstrap: { + name: 'propr-windows-bootstrap.node', + format: 'PE', + architecture: 'x64', + machine: 'AMD64', + size: 4096, + sha256: '9'.repeat(64), + trust: 'unsigned-validation', + publisher: null, + signerPins: [], + signerCertificateSha256: null, + signerSpkiSha256: null, + }, + compiler: { + kind: 'windows-catalog-authorized-dotnet-framework-csc-v1', + framework: 'Framework64-v4.0.30319', + signerCertificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + signerSpkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + signerRootSpkiSha256: '3'.repeat(64), + volumeSerial: '6'.repeat(16), + fileId128: '7'.repeat(32), + inputs: [ + compilerInputEvidence('csc.exe', 'c'.repeat(64)), + compilerInputEvidence('System.dll', 'd'.repeat(64)), + compilerInputEvidence('System.Web.Extensions.dll', 'e'.repeat(64)), + ], + }, + ...overrides, +})}\n`); + +test('Windows helper manifest is fatal-UTF8, exact, architecture-bound, and distinguishes unsigned validation', () => { + assert.equal(parseWindowsAuthorityHelperManifestForTest(helperManifest()).trust, 'unsigned-validation'); + const base = JSON.parse(helperManifest().toString()); + const certificate = '1'.repeat(64); + const spki = '2'.repeat(64); + const pins = [`certificate-sha256:${certificate}`, `spki-sha256:${spki}`].sort(); + const production = { + trust: 'production-signed', + publisher: 'CN=ProPR Test Publisher', + signerPins: pins, + signerCertificateSha256: certificate, + signerSpkiSha256: spki, + launcher: { + ...base.launcher, + trust: 'production-signed', + publisher: 'CN=ProPR Test Publisher', + signerPins: pins, + signerCertificateSha256: certificate, + signerSpkiSha256: spki, + }, + bootstrap: { + ...base.bootstrap, + trust: 'production-signed', + publisher: 'CN=ProPR Test Publisher', + signerPins: pins, + signerCertificateSha256: certificate, + signerSpkiSha256: spki, + }, + }; + assert.equal(parseWindowsAuthorityHelperManifestForTest(helperManifest(production)).trust, 'production-signed'); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ + ...production, signerPins: [], launcher: { ...production.launcher, signerPins: [] }, + })), /compile_load:4/, 'production cannot omit its cryptographic pin'); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ + ...production, + launcher: { ...production.launcher, signerSpkiSha256: '3'.repeat(64) }, + })), /compile_load:4/, 'a same-subject launcher signed by a different key cannot satisfy production'); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ sha256: '0'.repeat(63) })), /compile_load:4/); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ architecture: 'x64' })), /compile_load:4/); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ unexpected: true })), /compile_load:4/); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ + launcher: { ...base.launcher, architecture: 'arm64' }, + })), /compile_load:4/); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ + launcher: { ...base.launcher, sha256: '0'.repeat(63) }, + })), /compile_load:4/); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest({ + compiler: { + ...base.compiler, + inputs: [{ ...base.compiler.inputs[0], signerSpkiSha256: '8'.repeat(64) }, ...base.compiler.inputs.slice(1)], + }, + })), /compile_load:4/, 'mutable manifest replacement cannot rotate observed compiler authorization evidence'); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(Buffer.from([0xc3, 0x28, 0x0a])), /compile_load:4/); + assert.throws(() => parseWindowsAuthorityHelperManifestForTest(helperManifest().subarray(0, -1)), /compile_load:4/); +}); + +test('Windows helper PE inspection requires a managed PE32 AnyCPU-compatible image', () => { + const pe = Buffer.alloc(1024); + pe.writeUInt16LE(0x5a4d, 0); + pe.writeUInt32LE(0x80, 0x3c); + pe.write('PE\0\0', 0x80, 'ascii'); + pe.writeUInt16LE(0x14c, 0x84); + pe.writeUInt16LE(1, 0x86); + pe.writeUInt16LE(224, 0x94); + pe.writeUInt16LE(0x10b, 0x98); + pe.writeUInt32LE(0x2000, 0x98 + 96 + (14 * 8)); + pe.writeUInt32LE(72, 0x98 + 96 + (14 * 8) + 4); + pe.writeUInt32LE(0x200, 0x178 + 8); + pe.writeUInt32LE(0x2000, 0x178 + 12); + pe.writeUInt32LE(0x200, 0x178 + 16); + pe.writeUInt32LE(0x200, 0x178 + 20); + pe.writeUInt32LE(0x1, 0x210); + assert.doesNotThrow(() => inspectWindowsAuthorityHelperPeForTest(pe)); + const nativeOnly = Buffer.from(pe); + nativeOnly.writeUInt32LE(0, 0x98 + 96 + (14 * 8)); + assert.throws(() => inspectWindowsAuthorityHelperPeForTest(nativeOnly), /compile_load:9/); + const wrongMachine = Buffer.from(pe); + wrongMachine.writeUInt16LE(0x8664, 0x84); + assert.throws(() => inspectWindowsAuthorityHelperPeForTest(wrongMachine), /compile_load:9/); + const required32Bit = Buffer.from(pe); + required32Bit.writeUInt32LE(0x3, 0x210); + assert.throws(() => inspectWindowsAuthorityHelperPeForTest(required32Bit), /compile_load:9/); +}); + +test('production verifier is kernel-rooted and never selected by the process command environment', async () => { + const implementation = await readFile(fileURLToPath(new URL('./windows-update-authority.ts', import.meta.url)), 'utf8'); + assert.doesNotMatch(implementation, /require\(launcherProof\.path\)/); + assert.doesNotMatch(implementation, /process\.env\.(?:SystemRoot|windir|COMSPEC|PATH)/i); + assert.match(implementation, /GLOBALROOT\\SystemRoot\\System32\\WindowsPowerShell\\v1\.0\\powershell\.exe/); + assert.match(implementation, /const child = spawn\(KERNEL_SYSTEM_POWERSHELL/); + assert.match(implementation, /env: \{\}/); + assert.doesNotMatch(implementation, /\bfsutil\b|queryfileid|Get-Item|-LiteralPath|Get-Acl/); + assert.match(implementation, /stdio: \['pipe', 'pipe', 'pipe', heldHandle\.fd\]/); + assert.match(implementation, /\$heldHandle=\$native::_get_osfhandle\(3\)/); + assert.match(implementation, /GetFileInformationByHandleEx/); + assert.match(implementation, /GetSecurityInfo/); + assert.doesNotMatch(implementation, /Get-AuthenticodeSignature\s+-Content/); + assert.match(implementation, /WinVerifyTrust/); + assert.match(implementation, /CryptQueryObject\(2,\$blob/); + assert.match(implementation, /GCHandleType\]::Pinned/); + assert.match(implementation, /Invoke-HeldCatalogTrust \$memberHandle/); + assert.match(implementation, /CryptCATAdminCalcHashFromFileHandle2/); + assert.match(implementation, /CryptCATAdminEnumCatalogFromHash/); + assert.match(implementation, /selfCatalogFileId128/); + assert.match(implementation, /record\.nodeDev === nodeIdentity\.dev && record\.nodeIno === nodeIdentity\.ino/); + assert.match(implementation, /MICROSOFT_SYSTEM_ROOT_SPKI_SHA256\.has\(selfRootSpkiSha256\)/); + assert.ok(implementation.indexOf('acquireBootstrapPackageAuthority(') + < implementation.indexOf('require(bootstrapProof.path)')); + assert.match(implementation, /bootstrap\.loadVerifiedModule\(\{/); +}); + +test('bootstrap authority rejects a forged or split held-object identity record', () => { + const policy = { size: 4096, sha256: 'a'.repeat(64) }; + const identity = { dev: '1234', ino: '5678' }; + const record = { + sha256: policy.sha256, + size: policy.size, + volumeSerial: '1'.repeat(16), + fileId128: '2'.repeat(32), + nodeDev: identity.dev, + nodeIno: identity.ino, + ownerSid: 'S-1-5-18', + daclProtected: true, + reparseTag: '00000000', + subject: null, + certificate: null, + selfSubject: 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + selfCertificate: 'certificate', + selfRootCertificate: 'root', + selfCatalogName: 'Microsoft-Windows-PowerShell-ServerCore-Package~31bf3856ad364e35~amd64~~10.0.26100.32230.cat', + selfCatalogSha256: '2d2ac25e4f3cc782a886422964dffc851a66af354220923d96153738867d7866', + selfCatalogVolumeSerial: '4'.repeat(16), + selfCatalogFileId128: '5'.repeat(32), + }; + assert.equal(validateBootstrapIdentityRecordForTest(record, policy, identity), true); + assert.equal(validateBootstrapIdentityRecordForTest({ ...record, nodeIno: '5679' }, policy, identity), false); + assert.equal(validateBootstrapIdentityRecordForTest({ ...record, fileId128: '2'.repeat(31) }, policy, identity), false); + assert.equal(validateBootstrapIdentityRecordForTest({ ...record, ownerSid: 'S-1-5-21-1-2-3-4' }, policy, identity), false); + assert.equal(validateBootstrapIdentityRecordForTest({ ...record, daclProtected: false }, policy, identity), false); + assert.equal(validateBootstrapIdentityRecordForTest({ ...record, systemAcl: true }, policy, identity), false); + assert.equal(validateBootstrapIdentityRecordForTest({ ...record, unexpected: true }, policy, identity), false); +}); + +test('hostile Windows command environment cannot select a verifier or execute its observable initializer', windowsOnly, + async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-hostile-windows-root-')); + const marker = join(root, 'fake-verifier-executed'); + const system32 = join(root, 'System32'); + const powershellDirectory = join(system32, 'WindowsPowerShell', 'v1.0'); + const prior = Object.fromEntries(['SystemRoot', 'windir', 'COMSPEC', 'PATH'].map(name => [name, process.env[name]])); + try { + await mkdir(powershellDirectory, { recursive: true }); + const observable = `@echo off\r\ntype nul > "${marker}"\r\nexit /b 127\r\n`; + await writeFile(join(powershellDirectory, 'powershell.exe'), observable); + await writeFile(join(system32, 'fsutil.exe'), observable); + await writeFile(join(root, 'cmd.exe'), observable); + process.env.SystemRoot = root; + process.env.windir = root; + process.env.COMSPEC = join(root, 'cmd.exe'); + process.env.PATH = `${powershellDirectory};${system32};${root}`; + const helper = await authenticateWindowsAuthorityHelperForTest(); + await helper.executableHandle.close(); + await helper.launcherHandle.close(); + await helper.bootstrapHandle.close(); + await helper.manifestHandle.close(); + await assert.rejects(readFile(marker), error => (error as NodeJS.ErrnoException).code === 'ENOENT'); + } finally { + for (const [name, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + await rm(root, { recursive: true, force: true }); + } + }); + +test('native pre-load swap barrier never transfers control to replacement N-API initialization', windowsOnly, async () => { + for (const fault of ['barrier-before-module-load-swap', 'barrier-before-module-load-write', + 'barrier-before-module-load-delete'] as const) { + const helper = await authenticateWindowsAuthorityHelperForTest(undefined, undefined, undefined, undefined, fault); + await helper.executableHandle.close(); + await helper.launcherHandle.close(); + await helper.bootstrapHandle.close(); + await helper.manifestHandle.close(); + } +}); + +test('OS package authority never executes a malicious replacement bootstrap initializer', windowsOnly, async () => { + const source = await authenticateWindowsAuthorityHelperForTest(); + const sourceDirectory = dirname(source.executable); + await source.executableHandle.close(); + await source.launcherHandle.close(); + await source.bootstrapHandle.close(); + await source.manifestHandle.close(); + const root = await mkdtemp(join(tmpdir(), 'propr-malicious-bootstrap-')); + const marker = join(root, 'initializer-executed'); + const publisher = 'CN=ProPR Malicious Fixture'; + const certificate = '1'.repeat(64); + const spki = '2'.repeat(64); + const pins = [`certificate-sha256:${certificate}`, `spki-sha256:${spki}`].sort(); + try { + const executable = join(root, 'propr-windows-authority.exe'); + const launcher = join(root, 'propr-windows-launcher.node'); + const bootstrap = join(root, 'propr-windows-bootstrap.node'); + const manifestPath = join(root, 'propr-windows-authority.manifest.json'); + const malicious = join(sourceDirectory, '..', '..', 'src', 'native', 'windows-launcher', 'build', 'Release', + 'propr_windows_malicious_bootstrap.node'); + await copyFile(source.executable, executable); + await copyFile(join(sourceDirectory, 'propr-windows-launcher.node'), launcher); + await copyFile(malicious, bootstrap); + const manifest = JSON.parse(await readFile(join(sourceDirectory, 'propr-windows-authority.manifest.json'), 'utf8')); + const maliciousBytes = await readFile(bootstrap); + for (const record of [manifest, manifest.launcher, manifest.bootstrap]) { + record.trust = 'production-signed'; + record.publisher = publisher; + record.signerPins = pins; + record.signerCertificateSha256 = certificate; + record.signerSpkiSha256 = spki; + } + manifest.bootstrap.size = maliciousBytes.length; + manifest.bootstrap.sha256 = createHash('sha256').update(maliciousBytes).digest('hex'); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + await sealWindowsAuthorityDirectory(root); + process.env.PROPR_WINDOWS_MALICIOUS_BOOTSTRAP_SIDE_EFFECT = marker; + await assert.rejects( + authenticateWindowsAuthorityHelperForTest(root, undefined, publisher, pins, undefined, false), + /compile_load:(?:5|6|7|8)/, + ); + await assert.rejects(readFile(marker), error => (error as NodeJS.ErrnoException).code === 'ENOENT'); + } finally { + delete process.env.PROPR_WINDOWS_MALICIOUS_BOOTSTRAP_SIDE_EFFECT; + await prepareWindowsAuthorityBuildDirectory(root).catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +test('raw production verifier accepts held invalid-UTF16 PE bytes then rejects a real same-root wrong leaf', windowsOnly, async () => { + const source = await authenticateWindowsAuthorityHelperForTest(); + const sourceDirectory = dirname(source.executable); + await source.executableHandle.close(); + await source.launcherHandle.close(); + await source.bootstrapHandle.close(); + await source.manifestHandle.close(); + const root = await mkdtemp(join(tmpdir(), 'propr-real-wrong-leaf-')); + const signingScript = join(root, 'sign-hostile-fixture.ps1'); + let certificateState: { root: string; actual: string; expected: string } | undefined; + try { + for (const name of ['propr-windows-authority.exe', 'propr-windows-launcher.node', + 'propr-windows-bootstrap.node', 'propr-windows-authority.manifest.json']) { + await copyFile(join(sourceDirectory, name), join(root, name)); + } + await writeFile(signingScript, String.raw` +$ErrorActionPreference='Stop' +$fixture=$args[0] +$ca=New-SelfSignedCertificate -Type Custom -Subject 'CN=ProPR Raw Fixture Root' -KeyUsage CertSign,CRLSign,DigitalSignature -KeyExportPolicy Exportable -TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=1') -CertStoreLocation Cert:\CurrentUser\My +$actual=New-SelfSignedCertificate -Type Custom -Subject 'CN=ProPR Raw Fixture Leaf' -Signer $ca -KeyUsage DigitalSignature -KeyExportPolicy Exportable -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') -CertStoreLocation Cert:\CurrentUser\My +$expected=New-SelfSignedCertificate -Type Custom -Subject 'CN=ProPR Raw Fixture Leaf' -Signer $ca -KeyUsage DigitalSignature -KeyExportPolicy Exportable -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') -CertStoreLocation Cert:\CurrentUser\My +$roots=New-Object Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser');$roots.Open('ReadWrite');$roots.Add($ca);$roots.Close() +$publishers=New-Object Security.Cryptography.X509Certificates.X509Store('TrustedPublisher','CurrentUser');$publishers.Open('ReadWrite');$publishers.Add($actual);$publishers.Close() +foreach($name in @('propr-windows-authority.exe','propr-windows-launcher.node','propr-windows-bootstrap.node')) { + $path=Join-Path $fixture $name + $stream=[IO.File]::Open($path,[IO.FileMode]::Append,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$invalidUtf16=[byte[]](0,216,255);$stream.Write($invalidUtf16,0,$invalidUtf16.Length)}finally{$stream.Dispose()} + $signed=Set-AuthenticodeSignature -LiteralPath $path -Certificate $actual -HashAlgorithm SHA256 + if($signed.Status -ne 'Valid'){throw 'fixture signing failed'} +} +@{root=$ca.Thumbprint;actual=$actual.Thumbprint;expected=$expected.Thumbprint;actualRaw=[Convert]::ToBase64String($actual.RawData);expectedRaw=[Convert]::ToBase64String($expected.RawData)}|ConvertTo-Json -Compress +`, 'utf8'); + const { stdout } = await execFileAsync(kernelPowerShell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', signingScript, root], + { env: {}, windowsHide: true, maxBuffer: 64 * 1024 }); + const signed = JSON.parse(stdout.trim()) as { + root: string; actual: string; expected: string; actualRaw: string; expectedRaw: string; + }; + certificateState = signed; + const actual = new X509Certificate(Buffer.from(signed.actualRaw, 'base64')); + const expected = new X509Certificate(Buffer.from(signed.expectedRaw, 'base64')); + const identity = (certificate: X509Certificate) => { + const certificateSha256 = certificate.fingerprint256.replaceAll(':', '').toLowerCase(); + const spkiSha256 = createHash('sha256').update( + certificate.publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); + return { + publisher: certificate.subject, + certificateSha256, + spkiSha256, + pins: [`certificate-sha256:${certificateSha256}`, `spki-sha256:${spkiSha256}`].sort(), + }; + }; + const actualIdentity = identity(actual); + const expectedIdentity = identity(expected); + const manifestPath = join(root, 'propr-windows-authority.manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const applyIdentity = (signer: ReturnType) => { + for (const record of [manifest, manifest.launcher, manifest.bootstrap]) { + record.trust = 'production-signed'; + record.publisher = signer.publisher; + record.signerPins = signer.pins; + record.signerCertificateSha256 = signer.certificateSha256; + record.signerSpkiSha256 = signer.spkiSha256; + } + }; + for (const [record, name] of [[manifest, 'propr-windows-authority.exe'], + [manifest.launcher, 'propr-windows-launcher.node'], [manifest.bootstrap, 'propr-windows-bootstrap.node']] as const) { + const bytes = await readFile(join(root, name)); + record.size = bytes.length; + record.sha256 = createHash('sha256').update(bytes).digest('hex'); + } + applyIdentity(actualIdentity); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + await rm(signingScript, { force: true }); + await sealWindowsAuthorityDirectory(root); + const accepted = await authenticateWindowsAuthorityHelperForTest( + root, undefined, actualIdentity.publisher, actualIdentity.pins, undefined, false, + ); + await accepted.executableHandle.close(); + await accepted.launcherHandle.close(); + await accepted.bootstrapHandle.close(); + await accepted.manifestHandle.close(); + await prepareWindowsAuthorityBuildDirectory(root); + applyIdentity(expectedIdentity); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + await sealWindowsAuthorityDirectory(root); + await assert.rejects( + authenticateWindowsAuthorityHelperForTest( + root, undefined, expectedIdentity.publisher, expectedIdentity.pins, undefined, false, + ), + /compile_load:(?:5|6|7|8)/, + ); + assert.equal(windowsAuthorityBrokerStatsForTest().activeProcessCount, 0); + } finally { + await prepareWindowsAuthorityBuildDirectory(root).catch(() => undefined); + if (certificateState) { + const cleanup = `$values=@('${certificateState.root}','${certificateState.actual}','${certificateState.expected}');` + + "foreach($storeName in @('My','Root','TrustedPublisher')){$store=New-Object Security.Cryptography.X509Certificates.X509Store($storeName,'CurrentUser');$store.Open('ReadWrite');foreach($certificate in @($store.Certificates)){if($values -contains $certificate.Thumbprint){$store.Remove($certificate)}};$store.Close()}"; + await execFileAsync(kernelPowerShell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', cleanup], + { env: {}, windowsHide: true }).catch(() => undefined); + } + await rm(root, { recursive: true, force: true }); + } +}); + +test('bootstrap authority rejects real unprotected, current-owner, explicit-write, and inherited-write ACL attacks', windowsOnly, + async t => { + await shutdownWindowsAuthorityBrokerForTest(); + const sourceDirectory = fileURLToPath(new URL('../build/windows-authority', import.meta.url)); + const malicious = fileURLToPath(new URL( + './native/windows-launcher/build/Release/propr_windows_malicious_bootstrap.node', import.meta.url, + )); + const { stdout } = await execFileAsync(kernelPowerShell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value'], { env: {}, windowsHide: true }); + const currentSid = stdout.trim(); + assert.match(currentSid, /^S-1-(?:\d+-){1,14}\d+$/); + for (const scenario of ['unprotected-dacl', 'current-owner', 'explicit-write', 'inherited-write'] as const) { + await t.test(scenario, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-bootstrap-acl-')); + const marker = join(root, 'initializer-executed'); + try { + for (const name of ['propr-windows-authority.exe', 'propr-windows-launcher.node']) { + await copyFile(join(sourceDirectory, name), join(root, name)); + } + const bootstrap = join(root, 'propr-windows-bootstrap.node'); + await copyFile(malicious, bootstrap); + const manifest = JSON.parse(await readFile(join(sourceDirectory, 'propr-windows-authority.manifest.json'), 'utf8')); + const bytes = await readFile(bootstrap); + manifest.bootstrap.size = bytes.length; + manifest.bootstrap.sha256 = createHash('sha256').update(bytes).digest('hex'); + await writeFile(join(root, 'propr-windows-authority.manifest.json'), `${JSON.stringify(manifest)}\n`); + await sealWindowsAuthorityDirectory(root); + if (scenario === 'unprotected-dacl') { + await execFileAsync(kernelIcacls, [bootstrap, '/inheritance:e', '/Q'], { env: {} }); + } else if (scenario === 'current-owner') { + await execFileAsync(kernelIcacls, [root, '/setowner', `*${currentSid}`, '/T', '/C', '/Q'], { env: {} }); + } else if (scenario === 'explicit-write') { + await execFileAsync(kernelIcacls, [bootstrap, '/grant', `*${currentSid}:M`, '/Q'], { env: {} }); + } else { + await execFileAsync(kernelIcacls, [root, '/inheritance:e', '/grant', `*${currentSid}:(OI)(CI)M`, '/Q'], + { env: {} }); + } + process.env.PROPR_WINDOWS_MALICIOUS_BOOTSTRAP_SIDE_EFFECT = marker; + await assert.rejects( + authenticateWindowsAuthorityHelperForTest(root, undefined, undefined, undefined, undefined, true), + /compile_load:(?:6|7)/, + ); + await assert.rejects(readFile(marker), error => (error as NodeJS.ErrnoException).code === 'ENOENT'); + assert.equal(windowsAuthorityBrokerStatsForTest().activeProcessCount, 0); + } finally { + delete process.env.PROPR_WINDOWS_MALICIOUS_BOOTSTRAP_SIDE_EFFECT; + await prepareWindowsAuthorityBuildDirectory(root).catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } + }); + } + }); + +test('native ACL policy rejects real arbitrary SID, object, callback, and conditional allow ACEs', windowsOnly, async () => { + const helper = await authenticateWindowsAuthorityHelperForTest(); + try { + assert.equal(typeof helper.launcher.dangerousAclForTest, 'function'); + for (const sddl of [ + 'O:SYG:SYD:(A;;GW;;;S-1-5-21-111111111-222222222-333333333-4444)', + 'O:SYG:SYD:(OA;;GW;00000000-0000-0000-0000-000000000001;;S-1-5-21-111111111-222222222-333333333-4444)', + 'O:SYG:SYD:(XA;;GW;;;S-1-5-21-111111111-222222222-333333333-4444)', + 'O:SYG:SYD:(XA;;GW;;;S-1-5-21-111111111-222222222-333333333-4444;(@User.Title == "untrusted"))', + ]) assert.equal(helper.launcher.dangerousAclForTest?.({ sddl }), true); + assert.equal(helper.launcher.dangerousAclForTest?.({ + sddl: 'O:SYG:SYD:(A;;GR;;;BU)(D;;GW;;;BU)', + }), true, 'an explicit deny after an explicit allow is non-canonical and must fail closed'); + assert.equal(helper.launcher.dangerousAclForTest?.({ + sddl: 'O:SYG:SYD:(D;;GW;;;BU)(A;;GR;;;BU)', + }), false, 'canonical deny/allow order with no effective untrusted write is safe'); + } finally { + await helper.executableHandle.close(); + await helper.launcherHandle.close(); + await helper.bootstrapHandle.close(); + await helper.manifestHandle.close(); + } +}); + +test('native Windows bootstrap reports every injected real boundary including early exit', windowsOnly, async () => { + for (const stage of WINDOWS_AUTHORITY_COMPILE_STAGES) { + assert.equal(await probeWindowsAuthorityBootstrapStageForTest(stage), stage); + } + assert.equal(await probeWindowsAuthorityProcessImageMismatchForTest(), 'HELPER_IDENTITY'); +}); + +test('native Windows helper authentication rejects manifest/output/compiler, link, reparse, and same-name ABA faults', windowsOnly, async t => { + const source = await authenticateWindowsAuthorityHelperForTest(); + const sourceDirectory = dirname(source.executable); + await source.executableHandle.close(); + await source.launcherHandle.close(); + await source.bootstrapHandle.close(); + await source.manifestHandle.close(); + await assert.rejects( + authenticateWindowsAuthorityHelperForTest(sourceDirectory, undefined, 'CN=Expected Production Publisher'), + /compile_load:4/, + 'an unsigned validation helper must never satisfy a production-publisher expectation', + ); + const sourceManifest = join(sourceDirectory, 'propr-windows-authority.manifest.json'); + + const fixture = async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-helper-')); + const executable = join(root, 'propr-windows-authority.exe'); + const manifest = join(root, 'propr-windows-authority.manifest.json'); + const launcher = join(root, 'propr-windows-launcher.node'); + const bootstrap = join(root, 'propr-windows-bootstrap.node'); + await copyFile(source.executable, executable); + await copyFile(join(sourceDirectory, 'propr-windows-launcher.node'), launcher); + await copyFile(join(sourceDirectory, 'propr-windows-bootstrap.node'), bootstrap); + await copyFile(sourceManifest, manifest); + return { root, executable, manifest, launcher, bootstrap }; + }; + + for (const scenario of ['manifest', 'output', 'compiler', 'hardlink', 'reparse', 'same-name-aba', + 'launcher-output', 'launcher-hardlink', 'launcher-reparse', 'launcher-same-name-aba', + 'bootstrap-output', 'bootstrap-hardlink', 'bootstrap-reparse', 'bootstrap-same-name-aba'] as const) { + await t.test(scenario, async () => { + const current = await fixture(); + let sealed = false; + try { + if (scenario === 'manifest') { + const bytes = await readFile(current.manifest); + bytes[12] ^= 1; + await writeFile(current.manifest, bytes); + } else if (scenario === 'output') { + const bytes = await readFile(current.executable); + bytes[bytes.length - 1] ^= 1; + await writeFile(current.executable, bytes); + } else if (scenario === 'compiler') { + const value = JSON.parse(await readFile(current.manifest, 'utf8')); + value.compiler.kind = 'path-lookup-csc'; + await writeFile(current.manifest, `${JSON.stringify(value)}\n`); + } else if (scenario === 'hardlink') { + await link(current.executable, join(current.root, 'alternate.exe')); + } else if (scenario === 'reparse') { + await rm(current.executable); + await symlink(source.executable, current.executable, 'file'); + } else if (scenario === 'launcher-output') { + const bytes = await readFile(current.launcher); + bytes[bytes.length - 1] ^= 1; + await writeFile(current.launcher, bytes); + } else if (scenario === 'launcher-hardlink') { + await link(current.launcher, join(current.root, 'alternate.node')); + } else if (scenario === 'launcher-reparse') { + await rm(current.launcher); + await symlink(join(sourceDirectory, 'propr-windows-launcher.node'), current.launcher, 'file'); + } else if (scenario === 'bootstrap-output') { + const bytes = await readFile(current.bootstrap); + bytes[bytes.length - 1] ^= 1; + await writeFile(current.bootstrap, bytes); + } else if (scenario === 'bootstrap-hardlink') { + await link(current.bootstrap, join(current.root, 'alternate-bootstrap.node')); + } else if (scenario === 'bootstrap-reparse') { + await rm(current.bootstrap); + await symlink(join(sourceDirectory, 'propr-windows-bootstrap.node'), current.bootstrap, 'file'); + } + const isReparse = scenario === 'reparse' || scenario === 'launcher-reparse' || scenario === 'bootstrap-reparse'; + const barrier = scenario === 'same-name-aba' || scenario === 'launcher-same-name-aba' + || scenario === 'bootstrap-same-name-aba' ? async () => { + await prepareWindowsAuthorityBuildDirectory(current.root); + const target = scenario === 'same-name-aba' ? current.executable + : scenario === 'launcher-same-name-aba' ? current.launcher : current.bootstrap; + const sourcePath = scenario === 'same-name-aba' ? source.executable + : join(sourceDirectory, scenario === 'launcher-same-name-aba' + ? 'propr-windows-launcher.node' : 'propr-windows-bootstrap.node'); + await rename(target, join(current.root, scenario === 'same-name-aba' ? 'displaced.exe' + : scenario === 'launcher-same-name-aba' ? 'displaced.node' : 'displaced-bootstrap.node')); + await copyFile(sourcePath, target); + await sealWindowsAuthorityDirectory(current.root); + sealed = true; + } : undefined; + if (!barrier && !isReparse) { + await sealWindowsAuthorityDirectory(current.root); + sealed = true; + } + await assert.rejects(authenticateWindowsAuthorityHelperForTest(current.root, barrier), /compile_load:(?:4|7|8|9)/); + } finally { + if (sealed) await prepareWindowsAuthorityBuildDirectory(current.root).catch(() => undefined); + await rm(current.root, { recursive: true, force: true }); + } + }); + } +}); + +test('native Windows parent boundary denies post-hash and post-create mutation and fails closed before READY', windowsOnly, + async () => { + for (const fault of ['barrier-after-hash-delete', 'barrier-after-hash-swap', 'barrier-after-hash-write', + 'barrier-before-create-delete', 'barrier-before-create-swap', 'barrier-before-create-write', + 'barrier-after-process-delete', 'barrier-after-process-swap', 'barrier-after-process-write'] as const) { + assert.equal(await probeWindowsAuthorityNativeBoundaryForTest(fault), 'READY'); + assert.equal(windowsAuthorityBrokerStatsForTest().activeProcessCount, 0); + } + assert.equal(await probeWindowsAuthorityNativeBoundaryForTest('extra-child'), 'READY'); + assert.equal(windowsAuthorityBrokerStatsForTest().activeProcessCount, 0); + for (const fault of ['job-assignment', 'parent-image-proof', 'pipe-substitution'] as const) { + assert.equal(await probeWindowsAuthorityNativeBoundaryForTest(fault), 'TRANSPORT_SPAWN'); + assert.equal(windowsAuthorityBrokerStatsForTest().activeProcessCount, 0); + } + }); + +test('native Windows direct broker fails closed on live stderr, slowloris, and response timeout faults', windowsOnly, async () => { + assert.equal(await injectWindowsAuthorityTransportFaultForTest('stderr'), 'stdio_protocol'); + assert.equal(await injectWindowsAuthorityTransportFaultForTest('slowloris'), 'timeout'); + assert.equal(await injectWindowsAuthorityTransportFaultForTest('timeout'), 'timeout'); +}); + +test('native Windows explicit inherited fault environment executes stderr and process-image faults', windowsOnly, async () => { + assert.equal(await injectWindowsAuthorityTransportFaultForTest('stderr'), 'stdio_protocol'); + assert.equal(await probeWindowsAuthorityProcessImageMismatchForTest(), 'HELPER_IDENTITY'); +}); + +test('Windows broker framing accepts partial JSON and rejects extra frames and strict compile failures', () => { + const compileFailure = '{"version":1,"type":"error","reason":"compile_load","scenario":0}\n'; + const encoded = encodeWindowsAuthorityFrameForTest(compileFailure.slice(0, -1)); + const frames = decodeWindowsAuthorityFramesForTest([ + encoded.subarray(0, 3), + encoded.subarray(3, 19), + encoded.subarray(19), + ]); + const failure = parseWindowsAuthorityStartupFailureForTest(frames[0]); + assert.equal( + failure.message, + 'Verified update cache authority inspection failed [win-authority:compile_load:0]', + ); + assert.throws( + () => decodeWindowsAuthorityFramesForTest([Buffer.concat([encoded, encoded])]), + error => error instanceof Error + && error.message === 'Verified update cache authority inspection failed [win-authority:stdio_protocol:16]', + ); + assert.throws( + () => decodeWindowsAuthorityFramesForTest([encoded.subarray(0, -1)]), + error => error instanceof Error + && error.message === 'Verified update cache authority inspection failed [win-authority:stdio_protocol:16]', + ); +}); + +test('native Windows authority binds protected owner DACL and complete file identity', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-authority-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted'); + await protectWindowsPrivateFile(artifact); + const first = await inspectWindowsPrivatePath(artifact); + const second = await inspectWindowsPrivatePath(artifact); + assert.match(first.identity.volumeSerial, /^[a-f0-9]{16}$/); + assert.match(first.identity.fileId128, /^[a-f0-9]{32}$/); + assert.deepEqual(first.identity, second.identity); + assert.equal(first.links, '1'); + assert.equal(first.reparseTag, '00000000'); + assert.equal(first.daclProtected, true); + assert.match(first.ownerSid, /^S-1-/); + assert.deepEqual(await smokeWindowsUpdateAuthority(artifact), [ + 'compile-load', + 'owner-sid', + 'dacl-protection', + 'file-id-info', + 'same-handle-sha256-sha1', + 'reparse-query', + 'no-share-lock', + 'ready-protocol', + 'held-read', + 'clean-shutdown', + ]); + const stats = windowsAuthorityBrokerStatsForTest(); + assert.equal(stats.compileCount, 1, 'all smoke and authority requests must share one compiled helper process'); + assert.equal(stats.activeProcessCount, 1); + assert.ok(stats.requestCount >= 8); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows purpose policy accepts empty setup files but requires exact non-empty artifacts', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-purpose-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const setupPath = join(cache, 'partial'); + await writeFile(setupPath, Buffer.alloc(0), { flag: 'wx' }); + await protectWindowsPrivateFile(setupPath); + const empty = await inspectWindowsPrivatePath(setupPath); + assert.equal(empty.size, '0'); + const emptyHeld = await openWindowsLockedArtifact(setupPath, 0, undefined, undefined, empty.identity); + assert.equal(emptyHeld.inspection.size, '0'); + await assert.rejects(emptyHeld.read(0, 1), /win-authority:request_protocol:1/); + await emptyHeld.verify(); + await emptyHeld.close(); + await assert.rejects(openWindowsLockedArtifact(setupPath, 1), /win-authority:type_link_size:5/); + + await writeFile(setupPath, Buffer.from('A'), { flag: 'r+' }); + const written = await inspectWindowsPrivatePath(setupPath); + assert.deepEqual(written.identity, empty.identity, 'later setup write must retain the protected file identity'); + const artifactSha256 = createHash('sha256').update('A').digest('hex'); + const held = await openWindowsLockedArtifact( + setupPath, + 1, + undefined, + undefined, + written.identity, + artifactSha256, + ); + await held.close(); + await assert.rejects( + openWindowsLockedArtifact(setupPath, 1, undefined, undefined, written.identity, '0'.repeat(64)), + /win-authority:hash_read:11/, + ); + + const oversized = join(cache, 'oversized'); + await writeFile(oversized, Buffer.alloc(0), { flag: 'wx' }); + await protectWindowsPrivateFile(oversized); + await truncate(oversized, 1024 * 1024 * 1024 + 64 * 1024 + 1); + await assert.rejects(inspectWindowsPrivatePath(oversized), /win-authority:type_link_size:5/); + + assert.equal(await injectWindowsAuthorityProtocolFaultForTest('wrong-purpose', setupPath, 1), 'request_protocol'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows broker serializes a concurrent queue within one practical aggregate latency budget', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-queue-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted-A'); + await protectWindowsPrivateFile(artifact); + const started = Date.now(); + const results = await Promise.all(Array.from( + { length: 16 }, + () => inspectWindowsPrivatePath(artifact), + )); + assert.ok(Date.now() - started < 30_000, '16 warm requests must finish within 30 seconds on hosted Windows'); + assert.ok(results.every(result => result.identity.fileId128 === results[0].identity.fileId128)); + assert.equal(windowsAuthorityBrokerStatsForTest().compileCount, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows queued cancellation is bounded and does not disturb the held authority handle', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-cancel-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted-A'); + await protectWindowsPrivateFile(artifact); + const held = await openWindowsLockedArtifact(artifact, 9); + const controller = new AbortController(); + const cancelled = inspectWindowsPrivatePath(artifact, false, controller.signal); + let queuedResolved = false; + const queued = inspectWindowsPrivatePath(artifact).then(result => { + queuedResolved = true; + return result; + }); + controller.abort(); + await assert.rejects(cancelled, error => error instanceof Error && error.name === 'AbortError'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(queuedResolved, false, 'queued authority work must wait until the held capability closes'); + assert.equal((await held.read(0, 9)).toString(), 'trusted-A'); + assert.equal(windowsAuthorityBrokerStatsForTest().queuedEntries, 1); + await held.close(); + assert.equal((await queued).identity.fileId128, held.inspection.identity.fileId128); + assert.equal(windowsAuthorityBrokerStatsForTest().queuedEntries, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows authority rejects foreign owner, every untrusted writer ACE, inherited ACEs, and junctions', windowsOnly, async t => { + for (const scenario of ['owner', 'broad', 'arbitrary-sid', 'inherited', 'junction'] as const) { + await t.test(scenario, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-authority-')); + try { + const cache = join(root, 'cache'); + if (scenario === 'inherited') { + await execFileAsync('icacls.exe', [root, '/grant', '*S-1-5-32-545:(OI)(CI)M']); + await mkdir(cache); + } else { + await ensureWindowsPrivateDirectory(cache); + } + if (scenario === 'owner') { + await execFileAsync('icacls.exe', [cache, '/setowner', '*S-1-5-32-544']); + } else if (scenario === 'broad') { + await execFileAsync('icacls.exe', [cache, '/grant', '*S-1-5-32-545:(OI)(CI)M']); + } else if (scenario === 'arbitrary-sid') { + await execFileAsync('icacls.exe', [cache, '/grant', '*S-1-5-32-546:(OI)(CI)M']); + } else if (scenario === 'junction') { + const target = join(root, 'target'); + await mkdir(target); + const junction = join(cache, 'junction'); + await symlink(target, junction, 'junction'); + const junctionStats = await lstat(junction); + assert.equal(junctionStats.isSymbolicLink(), true, 'fixture must be a real junction reparse point'); + await assert.rejects(inspectWindowsPrivatePath(junction, true), /win-authority:reparse_point:4/); + return; + } + await assert.rejects(inspectWindowsPrivatePath(cache, true), /authority inspection failed/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } +}); + +test('native Windows held reader denies replace/delete while exact bytes are consumed', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-handoff-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted-A'); + await protectWindowsPrivateFile(artifact); + const locked = await openWindowsLockedArtifact(artifact, 9); + try { + assert.equal(locked.inspection.sha256.length, 64); + assert.equal(locked.inspection.sha1.length, 40); + await assert.rejects(rename(artifact, join(cache, 'displaced'))); + await assert.rejects(writeFile(artifact, 'attacker-B')); + await assert.rejects(rm(artifact)); + assert.equal((await locked.read(0, 9)).toString(), 'trusted-A'); + assert.deepEqual((await locked.verify()).identity, locked.inspection.identity); + } finally { + await locked.close(); + } + assert.equal((await readFile(artifact)).toString(), 'trusted-A'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows exact-handle capability rejects hardlinks and emits only bounded reason codes', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-reasons-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted-A'); + await protectWindowsPrivateFile(artifact); + await link(artifact, join(cache, 'second-link')); + await assert.rejects( + openWindowsLockedArtifact(artifact, 9), + error => error instanceof Error + && /^Verified update cache authority inspection failed \[win-authority:type_link_size:5\]$/.test(error.message) + && !error.message.includes(root), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows capability reuses one compiled broker without accepting pathname B', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-restart-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted-A'); + await protectWindowsPrivateFile(artifact); + const first = await openWindowsLockedArtifact(artifact, 9); + assert.equal((await first.read(0, 9)).toString(), 'trusted-A'); + await first.close(); + const second = await openWindowsLockedArtifact(artifact, 9); + try { + assert.deepEqual(second.inspection.identity, first.inspection.identity); + assert.equal((await second.read(0, 9)).toString(), 'trusted-A'); + await second.verify(); + assert.equal(windowsAuthorityBrokerStatsForTest().compileCount, 1); + } finally { + await second.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows close/reopen rejects a stale held ID instead of accepting an ABA capability', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-stale-id-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted-A'); + await protectWindowsPrivateFile(artifact); + const closed = await openWindowsLockedArtifact(artifact, 9); + await closed.close(); + const reopened = await openWindowsLockedArtifact(artifact, 9); + assert.equal(await injectWindowsAuthorityHeldFaultForTest(reopened, 'stale-id'), 'request_protocol'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows broker crash releases its exact handle and restart reauthenticates A', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-crash-restart-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifact = join(cache, 'artifact'); + await writeFile(artifact, 'trusted-A'); + await protectWindowsPrivateFile(artifact); + const crashed = await openWindowsLockedArtifact(artifact, 9); + assert.equal(windowsAuthorityBrokerStatsForTest().compileCount, 1); + const queuedA = inspectWindowsPrivatePath(artifact); + const queuedB = inspectWindowsPrivatePath(artifact); + await crashWindowsLockedArtifactForTest(crashed); + await assert.rejects(queuedA, /win-authority:process_exit:19/); + await assert.rejects(queuedB, /win-authority:process_exit:19/); + await assert.rejects(crashed.read(0, 1), /win-authority:(?:clean_shutdown|process_exit)/); + const restarted = await openWindowsLockedArtifact(artifact, 9); + try { + assert.equal(windowsAuthorityBrokerStatsForTest().compileCount, 2); + assert.equal(windowsAuthorityBrokerStatsForTest().restartCount, 1); + assert.deepEqual(restarted.inspection.identity, crashed.inspection.identity); + assert.equal((await restarted.read(0, 9)).toString(), 'trusted-A'); + } finally { + await restarted.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows live broker rejects frame, ID, purpose, and identity faults without stale target state', windowsOnly, async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-win-live-faults-')); + try { + const cache = join(root, 'cache'); + await ensureWindowsPrivateDirectory(cache); + const artifactA = join(cache, 'artifact-A'); + const artifactB = join(cache, 'artifact-B'); + await writeFile(artifactA, 'trusted-A'); + await writeFile(artifactB, 'trusted-B'); + await protectWindowsPrivateFile(artifactA); + await protectWindowsPrivateFile(artifactB); + + assert.equal(await injectWindowsAuthorityProtocolFaultForTest('partial-frame', artifactA, 9), 'accepted'); + assert.equal(await injectWindowsAuthorityProtocolFaultForTest('wrong-identity', artifactA, 9), 'final_verify'); + const displaced = join(cache, 'displaced'); + await rename(artifactA, displaced); + await rename(displaced, artifactA); + + for (const fault of ['wrong-id', 'wrong-purpose'] as const) { + const held = await openWindowsLockedArtifact(artifactA, 9); + assert.equal(await injectWindowsAuthorityHeldFaultForTest(held, fault), 'request_protocol'); + await rename(artifactA, displaced); + await rename(displaced, artifactA); + } + + const identityA = (await inspectWindowsPrivatePath(artifactA)).identity; + const beforeCancellation = windowsAuthorityBrokerStatsForTest().compileCount; + const controller = new AbortController(); + await assert.rejects( + openWindowsLockedArtifact( + artifactA, + 9, + async () => controller.abort(), + controller.signal, + identityA, + ), + error => error instanceof Error && error.name === 'AbortError', + ); + await rename(artifactA, displaced); + await rename(displaced, artifactA); + await inspectWindowsPrivatePath(artifactA); + assert.equal(windowsAuthorityBrokerStatsForTest().compileCount, beforeCancellation + 1); + + const beforeExtra = windowsAuthorityBrokerStatsForTest(); + assert.equal(await injectWindowsAuthorityProtocolFaultForTest('extra-frame', artifactA, 9), 'stdio_protocol'); + const restarted = await openWindowsLockedArtifact(artifactB, 9); + try { + assert.equal((await restarted.read(0, 9)).toString(), 'trusted-B'); + assert.equal( + windowsAuthorityBrokerStatsForTest().compileCount, + beforeExtra.compileCount + 1, + 'one replacement process must launch exactly one authenticated compiled helper', + ); + } finally { + await restarted.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native Windows persistent broker is reaped without a handle or process leak', windowsOnly, async () => { + await shutdownWindowsAuthorityBrokerForTest(); + const stats = windowsAuthorityBrokerStatsForTest(); + assert.equal(stats.activeProcessCount, 0); + assert.equal(stats.queuedEntries, 0); +}); diff --git a/apps/desktop/src/windows-update-authority.ts b/apps/desktop/src/windows-update-authority.ts new file mode 100644 index 000000000..487358d8a --- /dev/null +++ b/apps/desktop/src/windows-update-authority.ts @@ -0,0 +1,2333 @@ +import { createHash, randomBytes, X509Certificate } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { constants as fsConstants, createReadStream, createWriteStream } from 'node:fs'; +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { TextDecoder } from 'node:util'; +import { createRequire } from 'node:module'; +import { EventEmitter } from 'node:events'; +import type { Readable, Writable } from 'node:stream'; + +export interface WindowsFileIdentity { + platform: 'win32'; + volumeSerial: string; + fileId128: string; +} + +export interface WindowsPrivatePathInspection { + identity: WindowsFileIdentity; + directory: boolean; + links: string; + size: string; + reparseTag: string; + ownerSid: string; + daclProtected: true; + aceCount: string; + inheritedWriteAces: '0'; + broadWriteAces: '0'; +} + +export interface WindowsHeldVerification extends WindowsPrivatePathInspection { + sha256: string; + sha1: string; +} + +export interface WindowsLockedArtifact { + readonly inspection: WindowsHeldVerification; + read(offset: number, length: number, signal?: AbortSignal): Promise; + verify(signal?: AbortSignal): Promise; + close(signal?: AbortSignal): Promise; +} + +export const WINDOWS_AUTHORITY_PROTOCOL_VERSION = 1 as const; +export const WINDOWS_AUTHORITY_REASON_CODES = Object.freeze([ + 'compile_load', + 'request_protocol', + 'open_handle', + 'reparse_query', + 'reparse_point', + 'type_link_size', + 'owner_sid', + 'dacl_protection', + 'dacl_ace', + 'file_id_info', + 'no_share_lock', + 'hash_read', + 'ready_protocol', + 'held_read', + 'final_verify', + 'clean_shutdown', + 'stdio_protocol', + 'output_bound', + 'timeout', + 'process_exit', +] as const); + +type WindowsAuthorityReason = typeof WINDOWS_AUTHORITY_REASON_CODES[number]; +type BrokerOperation = 'inspect' | 'ensure-directory' | 'protect-directory' | 'protect-file'; +type BrokerPurpose = 'setup' | 'artifact'; + +export const WINDOWS_AUTHORITY_COMPILE_STAGES = Object.freeze([ + 'BUILD_COMPILER', + 'BUILD_SOURCE', + 'BUILD_OUTPUT', + 'TRANSPORT_SPAWN', + 'MANIFEST', + 'HELPER_OPEN', + 'HELPER_OWNER_DACL', + 'HELPER_REPARSE', + 'HELPER_IDENTITY', + 'HELPER_HASH', + 'PROTOCOL_INIT', + 'READY', +] as const); +export type WindowsAuthorityCompileStage = typeof WINDOWS_AUTHORITY_COMPILE_STAGES[number]; + +const BROKER_TIMEOUT_MS = 10_000; +const BROKER_STARTUP_TIMEOUT_MS = 60_000; +const BROKER_SESSION_TIMEOUT_MS = 10 * 60_000; +const BROKER_OUTPUT_BYTES = 16 * 1024; +const BROKER_PROTOCOL_LINE_BYTES = 2 * 1024 * 1024; +const BROKER_REQUEST_LINE_BYTES = 16 * 1024; +const BROKER_MAX_FRAMES = 8192; +const BROKER_MAX_INPUT_BYTES = 64 * 1024 * 1024; +const BROKER_MAX_OUTPUT_BYTES = 2 * 1024 * 1024 * 1024; +const BROKER_MAX_QUEUE_ENTRIES = 256; +const BROKER_ARTIFACT_BYTES = 1024 * 1024 * 1024; +const BROKER_SETUP_FILE_BYTES = 1024 * 1024 * 1024 + 64 * 1024; +const MAX_READ_BYTES = 1024 * 1024; +const reasonCodes = new Set(WINDOWS_AUTHORITY_REASON_CODES); +const INSPECTION_KEYS = Object.freeze([ + 'version', 'type', 'volumeSerial', 'fileId128', 'directory', 'links', 'size', 'reparseTag', + 'ownerSid', 'daclProtected', 'aceCount', 'inheritedWriteAces', 'broadWriteAces', 'sha256', 'sha1', +] as const); +const lockedArtifactProcesses = new WeakMap(); + +const HELPER_NAME = 'propr-windows-authority.exe'; +const HELPER_MANIFEST_NAME = 'propr-windows-authority.manifest.json'; +const LAUNCHER_NAME = 'propr-windows-launcher.node'; +const BOOTSTRAP_NAME = 'propr-windows-bootstrap.node'; +const HELPER_MAX_BYTES = 4 * 1024 * 1024; +const HELPER_MANIFEST_BYTES = 16 * 1024; +const HELPER_MANIFEST_KEYS = Object.freeze([ + 'schemaVersion', 'name', 'format', 'architecture', 'machine', 'clr', 'size', 'sha256', 'sourceSha256', + 'protocol', 'trust', 'publisher', 'compiler', + 'signerPins', 'signerCertificateSha256', 'signerSpkiSha256', + 'bootstrap', 'launcher', +] as const); + +interface WindowsNativeLauncherPolicy { + name: typeof LAUNCHER_NAME | typeof BOOTSTRAP_NAME; + format: 'PE'; + architecture: 'x64' | 'arm64'; + machine: 'AMD64' | 'ARM64'; + size: number; + sha256: string; + trust: 'unsigned-validation' | 'production-signed'; + publisher: string | null; + signerPins: readonly string[]; + signerCertificateSha256: string | null; + signerSpkiSha256: string | null; +} + +interface WindowsAuthorityHelperManifest { + schemaVersion: 1; + name: typeof HELPER_NAME; + format: 'PE32'; + architecture: 'anycpu'; + machine: 'I386'; + clr: true; + size: number; + sha256: string; + sourceSha256: string; + protocol: 'propr-windows-authority-v1'; + trust: 'unsigned-validation' | 'production-signed'; + publisher: string | null; + signerPins: readonly string[]; + signerCertificateSha256: string | null; + signerSpkiSha256: string | null; + launcher: WindowsNativeLauncherPolicy; + bootstrap: WindowsNativeLauncherPolicy; + compiler: { + kind: 'windows-catalog-authorized-dotnet-framework-csc-v1'; + framework: string; + signerCertificateSha256: string; + signerSpkiSha256: string; + signerRootSpkiSha256: string; + volumeSerial: string; + fileId128: string; + inputs: readonly { + name: string; + size: number; + sha256: string; + signerCertificateSha256: string; + signerSpkiSha256: string; + signerRootSpkiSha256: string; + catalogName: string; + catalogSha256: string; + catalogVolumeSerial: string; + catalogFileId128: string; + }[]; + }; +} + +interface AuthenticatedWindowsAuthorityHelper { + executable: string; + executableHandle: FileHandle; + launcherHandle: FileHandle; + bootstrapHandle: FileHandle; + manifestHandle: FileHandle; + manifest: WindowsAuthorityHelperManifest; + launcher: WindowsNativeLauncher; +} + +interface NativeLaunchLease { + lease: object; + stdinFd: number; + stdoutFd: number; + stderrFd: number; + pid: number; + volumeSerial: string; + fileId128: string; +} + +interface WindowsNativeLauncher { + launch(policy: Record): NativeLaunchLease; + status(lease: object): number | null; + closeInput(lease: object): void; + terminate(lease: object): void; + close(lease: object): void; + compileHeld?(policy: Record): Record; + dangerousAclForTest?(policy: { sddl: string }): boolean; +} + +interface WindowsNativeBootstrap { + loadVerifiedModule(policy: Record): WindowsNativeLauncher; +} + +interface BrokerChild extends EventEmitter { + stdin: Writable; + stdout: Readable; + stderr: Readable; + exitCode: number | null; + killed: boolean; + imageVolumeSerial: string; + imageFileId128: string; + kill(): boolean; + unref(): void; +} + +const require = createRequire(import.meta.url); + +// This namespace is resolved by the Windows object manager, not by the child +// environment inherited from an attacker-controlled launcher. +const KERNEL_SYSTEM_POWERSHELL = String.raw`\\?\GLOBALROOT\SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe`; +const MICROSOFT_SYSTEM_ROOT_SPKI_SHA256 = new Set([ + '02376d0908ac23041cc7d666d9daf192554f7fc36317aa9cb800908616b28af8', + 'c9905b0ee01202293ca026e64f08412442c5504c06e44ca7e9726d61f20e4089', + 'b2f7298b52bf2c3cac4ddfe72de4d682ac58957595982f2b62301af597c699c5', +]); +const MICROSOFT_SYSTEM_CATALOG_POLICY = Object.freeze([ + Object.freeze({ + member: 'powershell.exe', + catalog: 'Microsoft-Windows-PowerShell-ServerCore-Package~31bf3856ad364e35~amd64~~10.0.26100.32230.cat', + publisher: 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + certificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + spkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + catalogSha256: '2d2ac25e4f3cc782a886422964dffc851a66af354220923d96153738867d7866', + }), + Object.freeze({ + member: 'powershell.exe', + catalog: 'Microsoft-Windows-Client-Features-Package02~31bf3856ad364e35~arm64~~10.0.26100.1.cat', + publisher: 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + certificateSha256: 'ce08760345bd5a18aa9091e6f083522ad593bd42f587699e025afd55be589334', + spkiSha256: '130dc613f271c90adf66157a030391c404f1e4ca21ef8261ac914fc615298b62', + catalogSha256: '08150f5768c0780ab94d998a4302718fd1a69d6e54220a057f2d16f691a4582c', + }), +]); +const MICROSOFT_COMPILER_CATALOG_POLICY = Object.freeze( + ['csc.exe', 'System.dll', 'System.Web.Extensions.dll'].flatMap(name => [ + Object.freeze({ + name, + architecture: 'x64', + catalogName: 'Package_4_for_KB5066128~31bf3856ad364e35~amd64~~10.0.9321.3.cat', + certificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + spkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + catalogSha256: 'f447c801fde63f353448d90567363190964bb2e716c271256dba5859aaece7ef', + }), + Object.freeze({ + name, + architecture: 'arm64', + catalogName: 'Package_2_for_KB5066128~31bf3856ad364e35~arm64~~10.0.9321.3.cat', + certificateSha256: '1308aad34660d785a76b7360c31308d8835cf5721c364a6f5aedcba85eb5b3de', + spkiSha256: 'a693625901b3bb9292a8c61aa3b75e80027d578ee01501005a4761dabbf1b7d1', + catalogSha256: 'fd4c63e1001a82816e4ac3cdc76af05a7a02096a7101b4ddd3963d23ab773b85', + }), + ]), +); + +const BOOTSTRAP_AUTHORITY_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$policy = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadLine())) | ConvertFrom-Json +$trustedOwners = @('S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') +$trustedPublishers = @( + 'CN=Microsoft Windows, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US', + 'CN=Microsoft Windows, O=Microsoft Corporation, C=US', + 'CN=Microsoft Corporation, O=Microsoft Corporation, C=US' +) +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$currentAuthorities = New-Object Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase) +[void]$currentAuthorities.Add($identity.User.Value) +foreach ($group in $identity.Groups) {[void]$currentAuthorities.Add($group.Value)} +$assembly = [AppDomain]::CurrentDomain.DefineDynamicAssembly( + (New-Object Reflection.AssemblyName('ProprHeldObjectNative')), [Reflection.Emit.AssemblyBuilderAccess]::Run) +$module = $assembly.DefineDynamicModule('ProprHeldObjectNative') +$builder = $module.DefineType('ProprHeldObjectNative.Methods', [Reflection.TypeAttributes]'Public,Sealed,Abstract') +function Add-PInvoke([string]$name, [string]$library, [Type]$returnType, [Type[]]$parameterTypes, + [Runtime.InteropServices.CharSet]$charSet = [Runtime.InteropServices.CharSet]::Auto) { + $method = $builder.DefinePInvokeMethod($name, $library, + [Reflection.MethodAttributes]'Public,Static,PinvokeImpl', [Reflection.CallingConventions]::Standard, + $returnType, $parameterTypes, [Runtime.InteropServices.CallingConvention]::Winapi, $charSet) + $method.SetImplementationFlags($method.GetMethodImplementationFlags() -bor [Reflection.MethodImplAttributes]::PreserveSig) +} +$intptrRef = [IntPtr].MakeByRefType(); $uintRef = [uint32].MakeByRefType(); $ushortRef = [uint16].MakeByRefType() +$guidRef = [Guid].MakeByRefType() +$boolRef = [bool].MakeByRefType() +Add-PInvoke '_get_osfhandle' 'msvcrt.dll' ([IntPtr]) @([int]) +Add-PInvoke 'GetFileInformationByHandleEx' 'kernel32.dll' ([bool]) @([IntPtr], [int], [IntPtr], [uint32]) +Add-PInvoke 'GetFileInformationByHandle' 'kernel32.dll' ([bool]) @([IntPtr], [IntPtr]) +Add-PInvoke 'GetFinalPathNameByHandleW' 'kernel32.dll' ([uint32]) @([IntPtr], [Text.StringBuilder], [uint32], [uint32]) ([Runtime.InteropServices.CharSet]::Unicode) +Add-PInvoke 'CreateFileW' 'kernel32.dll' ([IntPtr]) @([string], [uint32], [uint32], [IntPtr], [uint32], [uint32], [IntPtr]) ([Runtime.InteropServices.CharSet]::Unicode) +Add-PInvoke 'CloseHandle' 'kernel32.dll' ([bool]) @([IntPtr]) +Add-PInvoke 'GetSecurityInfo' 'advapi32.dll' ([uint32]) @([IntPtr], [int], [uint32], $intptrRef, $intptrRef, $intptrRef, $intptrRef, $intptrRef) +Add-PInvoke 'GetSecurityDescriptorControl' 'advapi32.dll' ([bool]) @([IntPtr], $ushortRef, $uintRef) +Add-PInvoke 'GetSecurityDescriptorLength' 'advapi32.dll' ([uint32]) @([IntPtr]) +Add-PInvoke 'GetSecurityDescriptorDacl' 'advapi32.dll' ([bool]) @([IntPtr], $boolRef, $intptrRef, $boolRef) +Add-PInvoke 'GetAce' 'advapi32.dll' ([bool]) @([IntPtr], [uint32], $intptrRef) +Add-PInvoke 'ConvertSidToStringSidW' 'advapi32.dll' ([bool]) @([IntPtr], $intptrRef) +Add-PInvoke 'LocalFree' 'kernel32.dll' ([IntPtr]) @([IntPtr]) +Add-PInvoke 'CryptCATAdminAcquireContext2' 'wintrust.dll' ([bool]) @($intptrRef, $guidRef, [string], [IntPtr], [uint32]) ([Runtime.InteropServices.CharSet]::Unicode) +Add-PInvoke 'CryptCATAdminCalcHashFromFileHandle2' 'wintrust.dll' ([bool]) @([IntPtr], [IntPtr], $uintRef, [byte[]], [uint32]) +Add-PInvoke 'CryptCATAdminEnumCatalogFromHash' 'wintrust.dll' ([IntPtr]) @([IntPtr], [byte[]], [uint32], [uint32], $intptrRef) +Add-PInvoke 'CryptCATCatalogInfoFromContext' 'wintrust.dll' ([bool]) @([IntPtr], [IntPtr], [uint32]) +Add-PInvoke 'CryptCATAdminReleaseCatalogContext' 'wintrust.dll' ([bool]) @([IntPtr], [IntPtr], [uint32]) +Add-PInvoke 'CryptCATAdminReleaseContext' 'wintrust.dll' ([bool]) @([IntPtr], [uint32]) +Add-PInvoke 'WinVerifyTrust' 'wintrust.dll' ([int32]) @([IntPtr], $guidRef, [IntPtr]) +Add-PInvoke 'CryptQueryObject' 'crypt32.dll' ([bool]) @([uint32], [IntPtr], [uint32], [uint32], [uint32], $uintRef, $uintRef, $uintRef, $intptrRef, $intptrRef, [IntPtr]) +Add-PInvoke 'CryptMsgGetParam' 'crypt32.dll' ([bool]) @([IntPtr], [uint32], [uint32], [IntPtr], $uintRef) +Add-PInvoke 'CertEnumCertificatesInStore' 'crypt32.dll' ([IntPtr]) @([IntPtr], [IntPtr]) +Add-PInvoke 'CertFreeCertificateContext' 'crypt32.dll' ([bool]) @([IntPtr]) +Add-PInvoke 'CertCloseStore' 'crypt32.dll' ([bool]) @([IntPtr], [uint32]) +Add-PInvoke 'CryptMsgClose' 'crypt32.dll' ([bool]) @([IntPtr]) +$native = $builder.CreateType() +$catalogLeases=New-Object Collections.Generic.List[object] + +function Hex-Bytes([byte[]]$bytes) { ([BitConverter]::ToString($bytes)).Replace('-', '').ToLowerInvariant() } +function Read-Held([IO.FileStream]$stream, [int64]$expected, [int64]$maximum=4194304) { + if (!$stream.CanSeek -or $expected -le 0 -or $expected -gt $maximum) { throw 'size' } + $stream.Position = 0; $bytes = New-Object byte[] ([int]$expected); $offset = 0 + while ($offset -lt $bytes.Length) { $read = $stream.Read($bytes, $offset, $bytes.Length - $offset); if ($read -le 0) { throw 'read' }; $offset += $read } + if ($stream.ReadByte() -ne -1) { throw 'size' }; return $bytes +} +function Get-HeldIdentity([IntPtr]$handle, [bool]$directory) { + $tag = [Runtime.InteropServices.Marshal]::AllocHGlobal(8); $id = [Runtime.InteropServices.Marshal]::AllocHGlobal(24) + $basic = [Runtime.InteropServices.Marshal]::AllocHGlobal(52) + try { + if (!$native::GetFileInformationByHandleEx($handle, 9, $tag, 8) -or + !$native::GetFileInformationByHandleEx($handle, 18, $id, 24) -or + !$native::GetFileInformationByHandle($handle, $basic)) { throw 'identity' } + $attributes = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($tag, 0) + $reparse = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($tag, 4) + if (($attributes -band 0x400) -ne 0 -or $reparse -ne 0 -or (($attributes -band 0x10) -ne 0) -ne $directory) { throw 'type' } + $volumeBytes = New-Object byte[] 8; [Runtime.InteropServices.Marshal]::Copy($id, $volumeBytes, 0, 8) + $idBytes = New-Object byte[] 16; [Runtime.InteropServices.Marshal]::Copy([IntPtr]::Add($id, 8), $idBytes, 0, 16) + $indexHigh = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($basic, 44) + $indexLow = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($basic, 48) + $links = [uint32][Runtime.InteropServices.Marshal]::ReadInt32($basic, 40) + return @{ volumeSerial=([BitConverter]::ToUInt64($volumeBytes, 0)).ToString('x16'); fileId128=(Hex-Bytes $idBytes) + nodeDev=([BitConverter]::ToUInt64($volumeBytes, 0)).ToString(); nodeIno=(([uint64]$indexHigh -shl 32) -bor $indexLow).ToString() + links=$links.ToString(); reparseTag=$reparse.ToString('x8') } + } finally { [Runtime.InteropServices.Marshal]::FreeHGlobal($tag); [Runtime.InteropServices.Marshal]::FreeHGlobal($id); [Runtime.InteropServices.Marshal]::FreeHGlobal($basic) } +} +function Get-HeldSecurity([IntPtr]$handle) { + $owner=[IntPtr]::Zero; $group=[IntPtr]::Zero; $dacl=[IntPtr]::Zero; $sacl=[IntPtr]::Zero; $descriptor=[IntPtr]::Zero + if ($native::GetSecurityInfo($handle, 1, 5, [ref]$owner, [ref]$group, [ref]$dacl, [ref]$sacl, [ref]$descriptor) -ne 0 -or + $owner -eq [IntPtr]::Zero -or $dacl -eq [IntPtr]::Zero -or $descriptor -eq [IntPtr]::Zero) { throw 'security' } + try { + $ownerText=[IntPtr]::Zero; if (!$native::ConvertSidToStringSidW($owner, [ref]$ownerText)) { throw 'owner' } + try { $ownerSid=[Runtime.InteropServices.Marshal]::PtrToStringUni($ownerText) } finally { if ($ownerText -ne [IntPtr]::Zero) { [void]$native::LocalFree($ownerText) } } + if ($trustedOwners -notcontains $ownerSid -or $currentAuthorities.Contains($ownerSid)) { throw 'owner' } + $control=[uint16]0; $revision=[uint32]0 + if (!$native::GetSecurityDescriptorControl($descriptor, [ref]$control, [ref]$revision) -or + ($control -band 0x1000) -eq 0) { throw 'dacl-protection' } + $present=$false; $defaulted=$false; $actualDacl=[IntPtr]::Zero + if (!$native::GetSecurityDescriptorDacl($descriptor, [ref]$present, [ref]$actualDacl, [ref]$defaulted) -or !$present -or $actualDacl -eq [IntPtr]::Zero) { throw 'dacl' } + $descriptorLength=$native::GetSecurityDescriptorLength($descriptor) + if ($descriptorLength -le 0 -or $descriptorLength -gt 65536) {throw 'dacl'} + $descriptorBytes=New-Object byte[] $descriptorLength + [Runtime.InteropServices.Marshal]::Copy($descriptor,$descriptorBytes,0,$descriptorLength) + $raw=New-Object Security.AccessControl.RawSecurityDescriptor($descriptorBytes,0) + if (!$raw.DiscretionaryAcl) {throw 'dacl'} + $aceCount=$raw.DiscretionaryAcl.Count + $priorOrder=-1 + foreach ($ace in $raw.DiscretionaryAcl) { + if (($ace.AceFlags -band [Security.AccessControl.AceFlags]::InheritOnly) -ne 0) {continue} + $qualified=$ace -as [Security.AccessControl.QualifiedAce] + $known=$ace -as [Security.AccessControl.KnownAce] + if (!$qualified -or !$known -or !$known.SecurityIdentifier -or + ($qualified.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessAllowed -and + $qualified.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessDenied)) {throw 'ace'} + $allowed=$qualified.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessAllowed + $inherited=($ace.AceFlags -band [Security.AccessControl.AceFlags]::Inherited) -ne 0 + $order=if ($inherited) {if ($allowed) {3} else {2}} else {if ($allowed) {1} else {0}} + if ($order -lt $priorOrder) {throw 'ace-order'}; $priorOrder=$order + $mask=[uint32]$known.AccessMask + if (!$allowed -or ($mask -band [uint32]0x500D0156) -eq 0) {continue} + $sid=$known.SecurityIdentifier.Value + if ($currentAuthorities.Contains($sid) -or $trustedOwners -notcontains $sid) {throw 'ace'} + } + return @{ ownerSid=$ownerSid; daclProtected=$true; aceCount=$aceCount.ToString() } + } finally { if ($descriptor -ne [IntPtr]::Zero) {[void]$native::LocalFree($descriptor)} } +} +function Get-FinalPath([IntPtr]$handle) { $value=New-Object Text.StringBuilder 32768; $length=$native::GetFinalPathNameByHandleW($handle,$value,32768,0); if ($length -le 0 -or $length -ge 32768) {throw 'path'}; $value.ToString() } +function Invoke-HeldFileTrust([IntPtr]$handle, [string]$path) { + if ([IntPtr]::Size -ne 8) {throw 'wintrust-layout'} + $pathPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($path) + $file=[Runtime.InteropServices.Marshal]::AllocHGlobal(32); $data=[Runtime.InteropServices.Marshal]::AllocHGlobal(88) + try { + for ($offset=0;$offset -lt 32;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($file,$offset,0)} + for ($offset=0;$offset -lt 88;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($data,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($file,0,32) + [Runtime.InteropServices.Marshal]::WriteIntPtr($file,8,$pathPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($file,16,$handle) + [Runtime.InteropServices.Marshal]::WriteInt32($data,0,88) + [Runtime.InteropServices.Marshal]::WriteInt32($data,24,2) + [Runtime.InteropServices.Marshal]::WriteInt32($data,28,0) + [Runtime.InteropServices.Marshal]::WriteInt32($data,32,1) + [Runtime.InteropServices.Marshal]::WriteIntPtr($data,40,$file) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,1) + [Runtime.InteropServices.Marshal]::WriteInt32($data,72,0x1010) + $action=[Guid]'00AAC56B-CD44-11d0-8CC2-00C04FC295EE' + $status=$native::WinVerifyTrust([IntPtr](-1),[ref]$action,$data) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,2); [void]$native::WinVerifyTrust([IntPtr](-1),[ref]$action,$data) + if ($status -ne 0) {throw 'signature'} + } finally { + [Runtime.InteropServices.Marshal]::FreeHGlobal($data); [Runtime.InteropServices.Marshal]::FreeHGlobal($file) + [Runtime.InteropServices.Marshal]::FreeHGlobal($pathPointer) + } +} +function Invoke-HeldCatalogTrust([IntPtr]$memberHandle, [string]$memberPath, [string]$catalogPath, [byte[]]$memberHash, [IntPtr]$admin) { + if ([IntPtr]::Size -ne 8) {throw 'wintrust-layout'} + $memberTag=(Hex-Bytes $memberHash).ToUpperInvariant() + if ($memberTag.Length -ne $memberHash.Length*2) {throw 'member-tag'} + $catalogPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($catalogPath) + $tagPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($memberTag) + $memberPointer=[Runtime.InteropServices.Marshal]::StringToHGlobalUni($memberPath) + $pin=[Runtime.InteropServices.GCHandle]::Alloc($memberHash,[Runtime.InteropServices.GCHandleType]::Pinned) + $catalog=[Runtime.InteropServices.Marshal]::AllocHGlobal(72); $data=[Runtime.InteropServices.Marshal]::AllocHGlobal(88) + try { + for ($offset=0;$offset -lt 72;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($catalog,$offset,0)} + for ($offset=0;$offset -lt 88;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($data,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($catalog,0,72) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,8,$catalogPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,16,$tagPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,24,$memberPointer) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,32,$memberHandle) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,40,$pin.AddrOfPinnedObject()) + [Runtime.InteropServices.Marshal]::WriteInt32($catalog,48,$memberHash.Length) + [Runtime.InteropServices.Marshal]::WriteIntPtr($catalog,64,$admin) + [Runtime.InteropServices.Marshal]::WriteInt32($data,0,88) + [Runtime.InteropServices.Marshal]::WriteInt32($data,24,2) + [Runtime.InteropServices.Marshal]::WriteInt32($data,28,0) + [Runtime.InteropServices.Marshal]::WriteInt32($data,32,2) + [Runtime.InteropServices.Marshal]::WriteIntPtr($data,40,$catalog) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,1) + [Runtime.InteropServices.Marshal]::WriteInt32($data,72,0x1010) + $policy=[Guid]'00AAC56B-CD44-11d0-8CC2-00C04FC295EE' + $status=$native::WinVerifyTrust([IntPtr](-1),[ref]$policy,$data) + [Runtime.InteropServices.Marshal]::WriteInt32($data,48,2); [void]$native::WinVerifyTrust([IntPtr](-1),[ref]$policy,$data) + if ($status -ne 0) {throw 'catalog-trust'} + } finally { + [Runtime.InteropServices.Marshal]::FreeHGlobal($data); [Runtime.InteropServices.Marshal]::FreeHGlobal($catalog) + $pin.Free(); [Runtime.InteropServices.Marshal]::FreeHGlobal($memberPointer) + [Runtime.InteropServices.Marshal]::FreeHGlobal($tagPointer); [Runtime.InteropServices.Marshal]::FreeHGlobal($catalogPointer) + } +} +function Get-RawSigner([byte[]]$bytes, [bool]$standaloneCatalog) { + if ([IntPtr]::Size -ne 8 -or !$bytes -or $bytes.Length -le 0) {throw 'signer-parse'} + $pin=[Runtime.InteropServices.GCHandle]::Alloc($bytes,[Runtime.InteropServices.GCHandleType]::Pinned) + $blob=[Runtime.InteropServices.Marshal]::AllocHGlobal(16); $store=[IntPtr]::Zero; $message=[IntPtr]::Zero + try { + for ($offset=0;$offset -lt 16;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($blob,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($blob,0,$bytes.Length) + [Runtime.InteropServices.Marshal]::WriteIntPtr($blob,8,$pin.AddrOfPinnedObject()) + $encoding=[uint32]0; $content=[uint32]0; $format=[uint32]0 + $contentFlag=if ($standaloneCatalog) {[uint32]0x100} else {[uint32]0x400} + $expectedContent=if ($standaloneCatalog) {[uint32]8} else {[uint32]10} + if (!$native::CryptQueryObject(2,$blob,$contentFlag,2,0,[ref]$encoding,[ref]$content,[ref]$format,[ref]$store,[ref]$message,[IntPtr]::Zero) -or + $content -ne $expectedContent -or $format -ne 1 -or $store -eq [IntPtr]::Zero -or $message -eq [IntPtr]::Zero) {throw 'signer-parse'} + $signerBytes=[uint32]0 + if (!$native::CryptMsgGetParam($message,6,0,[IntPtr]::Zero,[ref]$signerBytes) -or $signerBytes -lt 32 -or $signerBytes -gt 65536) {throw 'signer-parse'} + $signer=[Runtime.InteropServices.Marshal]::AllocHGlobal([int]$signerBytes) + try { + if (!$native::CryptMsgGetParam($message,6,0,$signer,[ref]$signerBytes)) {throw 'signer-parse'} + $issuerLength=[Runtime.InteropServices.Marshal]::ReadInt32($signer,4); $issuerPointer=[Runtime.InteropServices.Marshal]::ReadIntPtr($signer,8) + $serialLength=[Runtime.InteropServices.Marshal]::ReadInt32($signer,16); $serialPointer=[Runtime.InteropServices.Marshal]::ReadIntPtr($signer,24) + if ($issuerLength -le 0 -or $issuerLength -gt 4096 -or $serialLength -le 0 -or $serialLength -gt 64) {throw 'signer-parse'} + $issuer=New-Object byte[] $issuerLength; [Runtime.InteropServices.Marshal]::Copy($issuerPointer,$issuer,0,$issuerLength) + $serial=New-Object byte[] $serialLength; [Runtime.InteropServices.Marshal]::Copy($serialPointer,$serial,0,$serialLength) + $certificate=$null; $previous=[IntPtr]::Zero + while ($true) { + $candidate=$native::CertEnumCertificatesInStore($store,$previous) + if ($candidate -eq [IntPtr]::Zero) {$previous=[IntPtr]::Zero; break} + $previous=$candidate; $parsed=New-Object Security.Cryptography.X509Certificates.X509Certificate2($candidate) + if ((Hex-Bytes $parsed.IssuerName.RawData) -ceq (Hex-Bytes $issuer) -and (Hex-Bytes $parsed.GetSerialNumber()) -ceq (Hex-Bytes $serial)) { + $certificate=New-Object Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$parsed.RawData) + $parsed.Dispose(); [void]$native::CertFreeCertificateContext($candidate); $previous=[IntPtr]::Zero; break + } + $parsed.Dispose() + } + if (!$certificate) {throw 'signer-parse'} + } finally {[Runtime.InteropServices.Marshal]::FreeHGlobal($signer)} + } finally { + if ($message -ne [IntPtr]::Zero) {[void]$native::CryptMsgClose($message)} + if ($store -ne [IntPtr]::Zero) {[void]$native::CertCloseStore($store,0)} + [Runtime.InteropServices.Marshal]::FreeHGlobal($blob); $pin.Free() + } + $root = $null + if ($certificate) { + $chain=New-Object Security.Cryptography.X509Certificates.X509Chain + try { + $chain.ChainPolicy.RevocationMode=[Security.Cryptography.X509Certificates.X509RevocationMode]::Offline + $chain.ChainPolicy.RevocationFlag=[Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot + [void]$chain.Build($certificate) + foreach ($status in $chain.ChainStatus) { + if ($status.Status -ne [Security.Cryptography.X509Certificates.X509ChainStatusFlags]::RevocationStatusUnknown -and + $status.Status -ne [Security.Cryptography.X509Certificates.X509ChainStatusFlags]::OfflineRevocation) {throw 'chain'} + } + if ($chain.ChainElements.Count -lt 2) {throw 'chain'} + $root=[Convert]::ToBase64String($chain.ChainElements[$chain.ChainElements.Count-1].Certificate.RawData) + } finally {$chain.Dispose()} + } + return @{subject=$certificate.Subject;certificate=[Convert]::ToBase64String($certificate.RawData);rootCertificate=$root} +} +function Test-Signature([IntPtr]$handle, [string]$path, [byte[]]$bytes, [bool]$standaloneCatalog, [bool]$required, [string]$expectedPublisher) { + if (!$required) {return @{subject=$null;certificate=$null;rootCertificate=$null}} + if (!$standaloneCatalog) {Invoke-HeldFileTrust $handle $path} + $signature=Get-RawSigner $bytes $standaloneCatalog + if (($standaloneCatalog -and $trustedPublishers -notcontains $signature.subject) -or + (!$standaloneCatalog -and $signature.subject -cne $expectedPublisher)) {throw 'signature'} + return $signature +} +function Get-SystemCatalogProof([IntPtr]$memberHandle, [string]$windowsRoot) { + $admin=[IntPtr]::Zero; $catalog=[IntPtr]::Zero; $previous=[IntPtr]::Zero + $action=[Guid]'F750E6C3-38EE-11D1-85E5-00C04FC295EE' + if (!$native::CryptCATAdminAcquireContext2([ref]$admin,[ref]$action,'SHA256',[IntPtr]::Zero,0)) {throw 'catalog-enumeration'} + try { + $hashBytes=[uint32]0 + if (!$native::CryptCATAdminCalcHashFromFileHandle2($admin,$memberHandle,[ref]$hashBytes,$null,0) -or $hashBytes -le 0 -or $hashBytes -gt 128) {throw 'catalog-hash'} + $memberHash=New-Object byte[] $hashBytes + if (!$native::CryptCATAdminCalcHashFromFileHandle2($admin,$memberHandle,[ref]$hashBytes,$memberHash,0)) {throw 'catalog-hash'} + $catalog=$native::CryptCATAdminEnumCatalogFromHash($admin,$memberHash,$hashBytes,0,[ref]$previous) + if ($catalog -eq [IntPtr]::Zero) {throw 'catalog-member'} + $info=[Runtime.InteropServices.Marshal]::AllocHGlobal(524) + try { + for ($offset=0;$offset -lt 524;$offset+=4) {[Runtime.InteropServices.Marshal]::WriteInt32($info,$offset,0)} + [Runtime.InteropServices.Marshal]::WriteInt32($info,0,524) + if (!$native::CryptCATCatalogInfoFromContext($catalog,$info,0)) {throw 'catalog-enumeration'} + $catalogPath=[Runtime.InteropServices.Marshal]::PtrToStringUni([IntPtr]::Add($info,4)) + } finally {[Runtime.InteropServices.Marshal]::FreeHGlobal($info)} + $catalogRoot=([IO.Path]::Combine($windowsRoot,'System32','CatRoot','{F750E6C3-38EE-11D1-85E5-00C04FC295EE}')).TrimEnd('\')+'\' + if (!$catalogPath.StartsWith($catalogRoot,[StringComparison]::OrdinalIgnoreCase) -or + $catalogPath.IndexOf('\',$catalogRoot.Length) -ge 0) {throw 'catalog-path'} + $stream=[IO.File]::Open($catalogPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + $handle=$stream.SafeFileHandle.DangerousGetHandle(); $identity=Get-HeldIdentity $handle $false; [void](Get-HeldSecurity $handle) + if (!(Get-FinalPath $handle).EndsWith($catalogPath,[StringComparison]::OrdinalIgnoreCase)) {throw 'catalog-path'} + Invoke-HeldCatalogTrust $memberHandle (Get-FinalPath $memberHandle) $catalogPath $memberHash $admin + $bytes=Read-Held $stream $stream.Length 33554432; $sha=[Security.Cryptography.SHA256]::Create() + try {$digest=Hex-Bytes $sha.ComputeHash($bytes)} finally {$sha.Dispose()} + $signature=Test-Signature $handle $catalogPath $bytes $true $true $null + $catalogLeases.Add([pscustomobject]@{stream=$stream;path=$catalogPath;sha256=$digest; + volumeSerial=$identity.volumeSerial;fileId128=$identity.fileId128;length=[int64]$stream.Length; + admin=$admin;catalog=$catalog}) + $admin=[IntPtr]::Zero; $catalog=[IntPtr]::Zero + return @{name=[IO.Path]::GetFileName($catalogPath);sha256=$digest;volumeSerial=$identity.volumeSerial;fileId128=$identity.fileId128;signature=$signature} + } catch {$stream.Dispose();throw} + } finally { + if ($catalog -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseCatalogContext($admin,$catalog,0)} + if ($admin -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseContext($admin,0)} + } +} + +# fd 3 is a duplicate of the exact Node-retained bootstrap handle. Every +# target fact below is queried from it; the pathname is opened only as a +# no-write/no-delete load lease and must resolve to the identical FILE_ID_128. +$heldHandle=$native::_get_osfhandle(3); if ($heldHandle -eq [IntPtr](-1)) {throw 'held'} +$heldSafe=New-Object Microsoft.Win32.SafeHandles.SafeFileHandle($heldHandle,$false) +$held=New-Object IO.FileStream($heldSafe,[IO.FileAccess]::Read,65536,$false) +$load=[IO.File]::Open($policy.path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) +$ancestorHandles=New-Object Collections.Generic.List[IntPtr] +$self=$null +try { + $heldIdentity=Get-HeldIdentity $heldHandle $false; $loadHandle=$load.SafeFileHandle.DangerousGetHandle() + $loadIdentity=Get-HeldIdentity $loadHandle $false + if ($heldIdentity.volumeSerial -cne $loadIdentity.volumeSerial -or $heldIdentity.fileId128 -cne $loadIdentity.fileId128 -or + $heldIdentity.nodeDev -cne $policy.nodeDev -or $heldIdentity.nodeIno -cne $policy.nodeIno -or $heldIdentity.links -cne '1') {throw 'split-handle'} + if ((Get-FinalPath $heldHandle) -cne (Get-FinalPath $loadHandle)) {throw 'load-path'} + $security=Get-HeldSecurity $heldHandle + $authorityRoot=[IO.Path]::GetFullPath($policy.authorityRoot).TrimEnd('\') + $cursor=[IO.Directory]::GetParent($policy.path); $rootSeen=$false + while ($cursor) { + $directory=$native::CreateFileW($cursor.FullName,0x80 -bor 0x20000,1,[IntPtr]::Zero,3,0x2200000,[IntPtr]::Zero) + if ($directory -eq [IntPtr](-1)) {throw 'ancestor'}; $ancestorHandles.Add($directory) + [void](Get-HeldIdentity $directory $true); [void](Get-HeldSecurity $directory) + if ($cursor.FullName.TrimEnd('\') -ieq $authorityRoot) {$rootSeen=$true; break}; $cursor=$cursor.Parent + } + if (!$rootSeen) {throw 'ancestor-root'} + $bytes=Read-Held $held ([int64]$policy.size); $sha=[Security.Cryptography.SHA256]::Create() + try {$digest=Hex-Bytes $sha.ComputeHash($bytes)} finally {$sha.Dispose()} + if ($digest -cne $policy.sha256) {throw 'hash'} + $signature=Test-Signature $heldHandle (Get-FinalPath $heldHandle) $bytes $false $policy.production $policy.publisher + $selfPath=[Diagnostics.Process]::GetCurrentProcess().MainModule.FileName + $self=[IO.File]::Open($selfPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $selfHandle=$self.SafeFileHandle.DangerousGetHandle() + if (!(Get-FinalPath $selfHandle).EndsWith('\System32\WindowsPowerShell\v1.0\powershell.exe',[StringComparison]::OrdinalIgnoreCase)) {throw 'self-path'} + $selfIdentity=Get-HeldIdentity $selfHandle $false; [void](Get-HeldSecurity $selfHandle) + $selfCursor=[IO.Directory]::GetParent($selfPath); $selfRoot=$selfCursor.Parent.Parent.Parent.FullName.TrimEnd('\'); $selfRootSeen=$false + while ($selfCursor) { + $selfDirectory=$native::CreateFileW($selfCursor.FullName,0x80 -bor 0x20000,1,[IntPtr]::Zero,3,0x2200000,[IntPtr]::Zero) + if ($selfDirectory -eq [IntPtr](-1)) {throw 'self-ancestor'}; $ancestorHandles.Add($selfDirectory) + [void](Get-HeldIdentity $selfDirectory $true); [void](Get-HeldSecurity $selfDirectory) + if ($selfCursor.FullName.TrimEnd('\') -ieq $selfRoot) {$selfRootSeen=$true; break}; $selfCursor=$selfCursor.Parent + } + if (!$selfRootSeen) {throw 'self-root'} + $selfCatalog=Get-SystemCatalogProof $selfHandle $selfRoot + [Console]::Out.WriteLine((@{sha256=$digest;size=[int64]$bytes.Length;volumeSerial=$heldIdentity.volumeSerial;fileId128=$heldIdentity.fileId128; + nodeDev=$heldIdentity.nodeDev;nodeIno=$heldIdentity.nodeIno;ownerSid=$security.ownerSid;daclProtected=$security.daclProtected;reparseTag=$heldIdentity.reparseTag; + subject=$signature.subject;certificate=$signature.certificate;selfCertificate=$selfCatalog.signature.certificate;selfRootCertificate=$selfCatalog.signature.rootCertificate; + selfSubject=$selfCatalog.signature.subject;selfCatalogName=$selfCatalog.name;selfCatalogSha256=$selfCatalog.sha256; + selfCatalogVolumeSerial=$selfCatalog.volumeSerial;selfCatalogFileId128=$selfCatalog.fileId128}|ConvertTo-Json -Compress)) + [Console]::Out.Flush(); if ([Console]::In.ReadLine() -cne 'release') {throw 'release'} + # Re-prove every retained capability after Node has initialized the bootstrap + # and launcher. No catalog/member/ACL swap at any held barrier can be hidden + # behind the earlier JSON record. + $heldFinal=Get-HeldIdentity $heldHandle $false; $loadFinal=Get-HeldIdentity $loadHandle $false + if ($heldFinal.volumeSerial -cne $heldIdentity.volumeSerial -or $heldFinal.fileId128 -cne $heldIdentity.fileId128 -or + $loadFinal.volumeSerial -cne $loadIdentity.volumeSerial -or $loadFinal.fileId128 -cne $loadIdentity.fileId128) {throw 'final-identity'} + [void](Get-HeldSecurity $heldHandle); [void](Get-HeldSecurity $loadHandle) + $finalBytes=Read-Held $held ([int64]$policy.size); $finalSha=[Security.Cryptography.SHA256]::Create() + try {$finalDigest=Hex-Bytes $finalSha.ComputeHash($finalBytes)} finally {$finalSha.Dispose()} + if ($finalDigest -cne $digest -or (Get-FinalPath $heldHandle) -cne (Get-FinalPath $loadHandle)) {throw 'final-bootstrap'} + $selfFinal=Get-HeldIdentity $selfHandle $false; [void](Get-HeldSecurity $selfHandle) + if ($selfFinal.volumeSerial -cne $selfIdentity.volumeSerial -or $selfFinal.fileId128 -cne $selfIdentity.fileId128) {throw 'final-self'} + foreach ($catalogLease in $catalogLeases) { + $catalogHandle=$catalogLease.stream.SafeFileHandle.DangerousGetHandle() + $catalogFinal=Get-HeldIdentity $catalogHandle $false; [void](Get-HeldSecurity $catalogHandle) + $catalogFinalPath=Get-FinalPath $catalogHandle + if ($catalogFinal.volumeSerial -cne $catalogLease.volumeSerial -or $catalogFinal.fileId128 -cne $catalogLease.fileId128 -or + !$catalogFinalPath.EndsWith($catalogLease.path,[StringComparison]::OrdinalIgnoreCase)) {throw 'final-catalog'} + $catalogBytes=Read-Held $catalogLease.stream $catalogLease.length 33554432; $catalogSha=[Security.Cryptography.SHA256]::Create() + try {$catalogDigest=Hex-Bytes $catalogSha.ComputeHash($catalogBytes)} finally {$catalogSha.Dispose()} + if ($catalogDigest -cne $catalogLease.sha256) {throw 'final-catalog'} + } + foreach ($handle in $ancestorHandles) {[void](Get-HeldSecurity $handle)} +} finally { + if ($self) {$self.Dispose()} + foreach ($catalogLease in $catalogLeases) { + if ($catalogLease.catalog -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseCatalogContext($catalogLease.admin,$catalogLease.catalog,0)} + if ($catalogLease.admin -ne [IntPtr]::Zero) {[void]$native::CryptCATAdminReleaseContext($catalogLease.admin,0)} + $catalogLease.stream.Dispose() + } + foreach ($handle in $ancestorHandles) {[void]$native::CloseHandle($handle)}; $load.Dispose(); $held.Dispose() +} +`; + +const helperError = (stage: WindowsAuthorityCompileStage): WindowsAuthorityBootstrapError => + new WindowsAuthorityBootstrapError('MALFORMED_OUTPUT', WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf(stage)); + +const helperDirectory = (): string => { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + if (resourcesPath && isAbsolute(resourcesPath)) return join(resourcesPath, 'windows-authority'); + return fileURLToPath(new URL('../build/windows-authority', import.meta.url)); +}; + +const embeddedExpectedPublisher = (): string | undefined => { + if (process.platform !== 'win32' || typeof __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__ === 'undefined') return undefined; + return __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__ || undefined; +}; + +const embeddedExpectedSignerPins = (): readonly string[] => { + if (process.platform !== 'win32' || typeof __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__ === 'undefined') return []; + return __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__; +}; + +export const validateBootstrapIdentityRecordForTest = ( + value: unknown, + policy: { size: number; sha256: string }, + nodeIdentity: { dev: string; ino: string }, +): value is Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const record = value as Record; + return exactRecordKeys(record, ['sha256', 'size', 'volumeSerial', 'fileId128', 'nodeDev', 'nodeIno', + 'ownerSid', 'daclProtected', 'reparseTag', 'subject', 'certificate', 'selfSubject', 'selfCertificate', 'selfRootCertificate', + 'selfCatalogName', 'selfCatalogSha256', 'selfCatalogVolumeSerial', 'selfCatalogFileId128']) + && record.sha256 === policy.sha256 && record.size === policy.size + && /^[a-f0-9]{16}$/.test(String(record.volumeSerial)) + && /^[a-f0-9]{32}$/.test(String(record.fileId128)) + && record.nodeDev === nodeIdentity.dev && record.nodeIno === nodeIdentity.ino + && ['S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'] + .includes(String(record.ownerSid)) + && record.daclProtected === true && record.reparseTag === '00000000' + && typeof record.selfSubject === 'string' && typeof record.selfCertificate === 'string' + && typeof record.selfRootCertificate === 'string' + && typeof record.selfCatalogName === 'string' && record.selfCatalogName.length <= 260 + && /^[a-f0-9]{64}$/.test(String(record.selfCatalogSha256)) + && /^[a-f0-9]{16}$/.test(String(record.selfCatalogVolumeSerial)) + && /^[a-f0-9]{32}$/.test(String(record.selfCatalogFileId128)) + && MICROSOFT_SYSTEM_CATALOG_POLICY.some(approved => approved.member === 'powershell.exe' + && approved.catalog === record.selfCatalogName && approved.catalogSha256 === record.selfCatalogSha256); +}; + +const acquireBootstrapPackageAuthority = async ( + path: string, + policy: WindowsNativeLauncherPolicy, + allowUnsignedValidation: boolean, + nodeIdentity: { dev: string; ino: string }, + heldHandle: FileHandle, +): Promise<() => Promise> => { + if (process.platform !== 'win32' || (policy.trust !== 'production-signed' && !allowUnsignedValidation)) { + throw helperError('HELPER_OWNER_DACL'); + } + const loader = '$p=[Console]::In.ReadLine();$s=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p));&([ScriptBlock]::Create($s))'; + const child = spawn(KERNEL_SYSTEM_POWERSHELL, ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-Command', loader], { + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe', heldHandle.fd], + // An explicit empty environment proves no hostile command/root variable is + // authority. The verifier obtains System32 from its own authenticated image. + env: {}, + }); + const childInput = child.stdin; + const childOutput = child.stdout; + const childError = child.stderr; + if (!childInput || !childOutput || !childError) { + if (!child.killed) child.kill(); + throw helperError('HELPER_OWNER_DACL'); + } + let output = Buffer.alloc(0); + let errorOutput = 0; + const cleanup = (): void => { if (!child.killed) child.kill(); }; + const proofPromise = new Promise>((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { cleanup(); rejectPromise(helperError('HELPER_OWNER_DACL')); }, 30_000); + const reject = (): void => { clearTimeout(timer); cleanup(); rejectPromise(helperError('HELPER_OWNER_DACL')); }; + child.once('error', reject); + child.once('exit', reject); + childError.on('data', (chunk: Buffer) => { + errorOutput += chunk.length; + if (errorOutput > 0) reject(); + }); + childOutput.on('data', (chunk: Buffer) => { + output = Buffer.concat([output, chunk]); + if (output.length > 16 * 1024) { reject(); return; } + const newline = output.indexOf(0x0a); + if (newline < 0) return; + if (output.subarray(newline + 1).some(byte => byte !== 0x0d && byte !== 0x0a)) { reject(); return; } + clearTimeout(timer); + try { resolvePromise(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(output.subarray(0, newline)))); } + catch { reject(); } + }); + }); + const wirePolicy = Buffer.from(JSON.stringify({ + path, + size: policy.size, + sha256: policy.sha256, + production: policy.trust === 'production-signed', + authorityRoot: dirname(path), + nodeDev: nodeIdentity.dev, + nodeIno: nodeIdentity.ino, + }), 'utf8').toString('base64'); + childInput.write(`${Buffer.from(BOOTSTRAP_AUTHORITY_SCRIPT, 'utf8').toString('base64')}\n${wirePolicy}\n`); + let record: Record; + try { record = await proofPromise; } catch (error) { cleanup(); throw error; } + if (!validateBootstrapIdentityRecordForTest(record, policy, nodeIdentity)) { + cleanup(); throw helperError('HELPER_IDENTITY'); + } + try { + const selfCertificate = new X509Certificate(Buffer.from(String(record.selfCertificate), 'base64')); + const selfRoot = new X509Certificate(Buffer.from(String(record.selfRootCertificate), 'base64')); + const selfCertificateSha256 = selfCertificate.fingerprint256.replaceAll(':', '').toLowerCase(); + const selfSpkiSha256 = createHash('sha256').update( + selfCertificate.publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); + const selfRootSpkiSha256 = createHash('sha256').update( + selfRoot.publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); + const approvedCatalog = MICROSOFT_SYSTEM_CATALOG_POLICY.some(approved => + approved.member === 'powershell.exe' + && approved.catalog === record.selfCatalogName + && approved.publisher === record.selfSubject + && approved.certificateSha256 === selfCertificateSha256 + && approved.spkiSha256 === selfSpkiSha256 + && approved.catalogSha256 === record.selfCatalogSha256); + if (!approvedCatalog || !MICROSOFT_SYSTEM_ROOT_SPKI_SHA256.has(selfRootSpkiSha256)) { + throw new Error('untrusted verifier'); + } + } catch { cleanup(); throw helperError('HELPER_OWNER_DACL'); } + if (policy.trust === 'production-signed') { + if (record.subject !== policy.publisher || typeof record.certificate !== 'string') { + cleanup(); throw helperError('HELPER_OWNER_DACL'); + } + let certificateSha256: string; + let spkiSha256: string; + try { + const certificate = new X509Certificate(Buffer.from(record.certificate, 'base64')); + certificateSha256 = certificate.fingerprint256.replaceAll(':', '').toLowerCase(); + spkiSha256 = createHash('sha256').update( + certificate.publicKey.export({ format: 'der', type: 'spki' }), + ).digest('hex'); + } catch { cleanup(); throw helperError('HELPER_OWNER_DACL'); } + if (certificateSha256 !== policy.signerCertificateSha256 || spkiSha256 !== policy.signerSpkiSha256 + || !policy.signerPins.some(pin => pin === `certificate-sha256:${certificateSha256}` + || pin === `spki-sha256:${spkiSha256}`)) { + cleanup(); throw helperError('HELPER_OWNER_DACL'); + } + } + return async () => { + childInput.end('release\n'); + await new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { cleanup(); rejectPromise(helperError('HELPER_OWNER_DACL')); }, 5_000); + child.once('exit', (code, signal) => { + clearTimeout(timer); + if (code === 0 && signal === null && errorOutput === 0) resolvePromise(); + else rejectPromise(helperError('HELPER_OWNER_DACL')); + }); + }); + }; +}; + +const exactRecordKeys = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); + +export const parseWindowsAuthorityHelperManifestForTest = (bytes: Buffer): WindowsAuthorityHelperManifest => { + if (!Buffer.isBuffer(bytes) || bytes.length <= 1 || bytes.length > HELPER_MANIFEST_BYTES + || bytes[bytes.length - 1] !== 0x0a) throw helperError('MANIFEST'); + let text: string; + try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, -1)); } + catch { throw helperError('MANIFEST'); } + let value: unknown; + try { value = JSON.parse(text); } catch { throw helperError('MANIFEST'); } + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw helperError('MANIFEST'); + const manifest = value as Record; + const compiler = manifest.compiler; + const launcher = manifest.launcher; + const bootstrap = manifest.bootstrap; + if (!exactRecordKeys(manifest, HELPER_MANIFEST_KEYS) + || typeof compiler !== 'object' || compiler === null || Array.isArray(compiler) + || typeof launcher !== 'object' || launcher === null || Array.isArray(launcher) + || typeof bootstrap !== 'object' || bootstrap === null || Array.isArray(bootstrap) + || !exactRecordKeys(compiler as Record, [ + 'kind', 'framework', 'signerCertificateSha256', 'signerSpkiSha256', 'signerRootSpkiSha256', + 'volumeSerial', 'fileId128', 'inputs', + ]) + || !exactRecordKeys(launcher as Record, [ + 'name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', 'publisher', 'signerPins', + 'signerCertificateSha256', 'signerSpkiSha256', + ]) + || !exactRecordKeys(bootstrap as Record, [ + 'name', 'format', 'architecture', 'machine', 'size', 'sha256', 'trust', 'publisher', 'signerPins', + 'signerCertificateSha256', 'signerSpkiSha256', + ]) + || manifest.schemaVersion !== 1 || manifest.name !== HELPER_NAME || manifest.format !== 'PE32' + || manifest.architecture !== 'anycpu' || manifest.machine !== 'I386' || manifest.clr !== true + || !Number.isSafeInteger(manifest.size) || Number(manifest.size) <= 0 || Number(manifest.size) > HELPER_MAX_BYTES + || !/^[a-f0-9]{64}$/.test(String(manifest.sha256)) + || !/^[a-f0-9]{64}$/.test(String(manifest.sourceSha256)) + || manifest.protocol !== 'propr-windows-authority-v1' + || !['unsigned-validation', 'production-signed'].includes(String(manifest.trust)) + || (manifest.trust === 'unsigned-validation' && manifest.publisher !== null) + || (manifest.trust === 'production-signed' + && (typeof manifest.publisher !== 'string' || manifest.publisher.length <= 0 || manifest.publisher.length > 512)) + || !Array.isArray(manifest.signerPins) || manifest.signerPins.length > 16 + || manifest.signerPins.some(pin => typeof pin !== 'string' + || !/^(?:certificate|spki)-sha256:[a-f0-9]{64}$/.test(pin)) + || new Set(manifest.signerPins).size !== manifest.signerPins.length + || manifest.signerPins.join(',') !== [...manifest.signerPins].sort().join(',') + || (manifest.trust === 'unsigned-validation' + && (manifest.signerPins.length !== 0 || manifest.signerCertificateSha256 !== null + || manifest.signerSpkiSha256 !== null)) + || (manifest.trust === 'production-signed' + && (manifest.signerPins.length === 0 + || !/^[a-f0-9]{64}$/.test(String(manifest.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(manifest.signerSpkiSha256)) + || !manifest.signerPins.some(pin => pin === `certificate-sha256:${manifest.signerCertificateSha256}` + || pin === `spki-sha256:${manifest.signerSpkiSha256}`))) + || (launcher as Record).name !== LAUNCHER_NAME + || (launcher as Record).format !== 'PE' + || !['x64', 'arm64'].includes(String((launcher as Record).architecture)) + || ((launcher as Record).architecture === 'x64' + ? (launcher as Record).machine !== 'AMD64' + : (launcher as Record).machine !== 'ARM64') + || !Number.isSafeInteger((launcher as Record).size) + || Number((launcher as Record).size) <= 0 + || Number((launcher as Record).size) > HELPER_MAX_BYTES + || !/^[a-f0-9]{64}$/.test(String((launcher as Record).sha256)) + || (launcher as Record).trust !== manifest.trust + || (launcher as Record).publisher !== manifest.publisher + || JSON.stringify((launcher as Record).signerPins) !== JSON.stringify(manifest.signerPins) + || (launcher as Record).signerCertificateSha256 !== manifest.signerCertificateSha256 + || (launcher as Record).signerSpkiSha256 !== manifest.signerSpkiSha256 + || (bootstrap as Record).name !== BOOTSTRAP_NAME + || (bootstrap as Record).format !== 'PE' + || (bootstrap as Record).architecture !== (launcher as Record).architecture + || (bootstrap as Record).machine !== (launcher as Record).machine + || !Number.isSafeInteger((bootstrap as Record).size) + || Number((bootstrap as Record).size) <= 0 + || Number((bootstrap as Record).size) > HELPER_MAX_BYTES + || !/^[a-f0-9]{64}$/.test(String((bootstrap as Record).sha256)) + || (bootstrap as Record).trust !== manifest.trust + || (bootstrap as Record).publisher !== manifest.publisher + || JSON.stringify((bootstrap as Record).signerPins) !== JSON.stringify(manifest.signerPins) + || (bootstrap as Record).signerCertificateSha256 !== manifest.signerCertificateSha256 + || (bootstrap as Record).signerSpkiSha256 !== manifest.signerSpkiSha256 + || (compiler as Record).kind !== 'windows-catalog-authorized-dotnet-framework-csc-v1' + || !/^(?:Framework64|Framework)-v4\.0\.30319$/.test(String((compiler as Record).framework)) + || !/^[a-f0-9]{64}$/.test(String((compiler as Record).signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String((compiler as Record).signerSpkiSha256)) + || !/^[a-f0-9]{64}$/.test(String((compiler as Record).signerRootSpkiSha256)) + || !/^[a-f0-9]{16}$/.test(String((compiler as Record).volumeSerial)) + || !/^[a-f0-9]{32}$/.test(String((compiler as Record).fileId128)) + || !Array.isArray((compiler as Record).inputs) + || ((compiler as Record).inputs as unknown[]).length !== 3 + || ((compiler as Record).inputs as Record[]) + .map(input => input?.name).join(',') !== 'csc.exe,System.dll,System.Web.Extensions.dll' + || ((compiler as Record).inputs as Record[]).some(input => + typeof input !== 'object' || input === null || Array.isArray(input) + || !exactRecordKeys(input, [ + 'name', 'size', 'sha256', 'signerCertificateSha256', 'signerSpkiSha256', 'signerRootSpkiSha256', + 'catalogName', 'catalogSha256', 'catalogVolumeSerial', 'catalogFileId128', + ]) || !Number.isSafeInteger(input.size) + || Number(input.size) <= 0 || Number(input.size) > 32 * 1024 * 1024 + || !/^[a-f0-9]{64}$/.test(String(input.sha256)) + || !/^[a-f0-9]{64}$/.test(String(input.signerCertificateSha256)) + || !/^[a-f0-9]{64}$/.test(String(input.signerSpkiSha256)) + || !/^[a-f0-9]{64}$/.test(String(input.signerRootSpkiSha256)) + || !/^[A-Za-z0-9_.~-]{1,180}\.cat$/.test(String(input.catalogName)) + || !/^[a-f0-9]{64}$/.test(String(input.catalogSha256)) + || !/^[a-f0-9]{16}$/.test(String(input.catalogVolumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(input.catalogFileId128)) + || !MICROSOFT_COMPILER_CATALOG_POLICY.some(approved => approved.name === input.name + && approved.architecture === (launcher as Record).architecture + && approved.catalogName === input.catalogName + && approved.certificateSha256 === input.signerCertificateSha256 + && approved.spkiSha256 === input.signerSpkiSha256 + && approved.catalogSha256 === input.catalogSha256)) + || ((compiler as Record).inputs as Record[])[0].signerCertificateSha256 + !== (compiler as Record).signerCertificateSha256 + || ((compiler as Record).inputs as Record[])[0].signerSpkiSha256 + !== (compiler as Record).signerSpkiSha256 + || ((compiler as Record).inputs as Record[])[0].signerRootSpkiSha256 + !== (compiler as Record).signerRootSpkiSha256) { + throw helperError('MANIFEST'); + } + return manifest as unknown as WindowsAuthorityHelperManifest; +}; + +export const inspectWindowsAuthorityHelperPeForTest = (bytes: Buffer): void => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > HELPER_MAX_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) throw helperError('HELPER_HASH'); + const pe = bytes.readUInt32LE(0x3c); + if (pe < 0x40 || pe + 248 > bytes.length || bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0' + || bytes.readUInt16LE(pe + 4) !== 0x14c || bytes.readUInt16LE(pe + 24) !== 0x10b) { + throw helperError('HELPER_HASH'); + } + const sectionCount = bytes.readUInt16LE(pe + 6); + const optionalSize = bytes.readUInt16LE(pe + 20); + const clrDirectory = pe + 24 + 96 + (14 * 8); + const clrRva = bytes.readUInt32LE(clrDirectory); + if (sectionCount <= 0 || sectionCount > 96 || optionalSize < 224 + || clrDirectory + 8 > pe + 24 + optionalSize || clrRva === 0 + || bytes.readUInt32LE(clrDirectory + 4) < 72) { + throw helperError('HELPER_HASH'); + } + const sectionTable = pe + 24 + optionalSize; + let clrOffset = -1; + for (let index = 0; index < sectionCount; index += 1) { + const section = sectionTable + (index * 40); + if (section + 40 > bytes.length) throw helperError('HELPER_HASH'); + const virtualSize = bytes.readUInt32LE(section + 8); + const virtualAddress = bytes.readUInt32LE(section + 12); + const rawSize = bytes.readUInt32LE(section + 16); + const rawAddress = bytes.readUInt32LE(section + 20); + const span = Math.max(virtualSize, rawSize); + if (clrRva >= virtualAddress && clrRva < virtualAddress + span) { + clrOffset = rawAddress + clrRva - virtualAddress; + } + } + if (clrOffset < 0 || clrOffset + 20 > bytes.length) throw helperError('HELPER_HASH'); + const corFlags = bytes.readUInt32LE(clrOffset + 16); + if ((corFlags & 0x1) === 0 || (corFlags & (0x2 | 0x10 | 0x20000)) !== 0) throw helperError('HELPER_HASH'); +}; + +export const inspectWindowsNativeLauncherPeForTest = (bytes: Buffer, architecture: 'x64' | 'arm64'): void => { + if (!Buffer.isBuffer(bytes) || bytes.length < 512 || bytes.length > HELPER_MAX_BYTES + || bytes.readUInt16LE(0) !== 0x5a4d) throw helperError('HELPER_HASH'); + const pe = bytes.readUInt32LE(0x3c); + const expectedMachine = architecture === 'arm64' ? 0xaa64 : 0x8664; + if (pe < 0x40 || pe + 24 > bytes.length || bytes.toString('ascii', pe, pe + 4) !== 'PE\0\0' + || bytes.readUInt16LE(pe + 4) !== expectedMachine) throw helperError('HELPER_HASH'); +}; + +const readHeldExactly = async (handle: FileHandle, size: number, stage: WindowsAuthorityCompileStage): Promise => { + const bytes = Buffer.alloc(size); + let offset = 0; + while (offset < size) { + const result = await handle.read(bytes, offset, size - offset, offset).catch(() => { throw helperError(stage); }); + if (result.bytesRead <= 0) throw helperError(stage); + offset += result.bytesRead; + } + return bytes; +}; + +const proveCanonicalTree = async (root: string, target: string): Promise<{ + path: string; + identity: { dev: bigint; ino: bigint; size: bigint; nlink: bigint }; +}> => { + const canonicalRoot = await realpath(root).catch(() => { throw helperError('HELPER_REPARSE'); }); + const canonicalTarget = await realpath(target).catch(() => { throw helperError('HELPER_REPARSE'); }); + const samePath = (left: string, right: string): boolean => process.platform === 'win32' + ? left.toLowerCase() === right.toLowerCase() + : left === right; + if (!samePath(resolve(root), canonicalRoot) || !samePath(resolve(target), canonicalTarget)) throw helperError('HELPER_REPARSE'); + const inside = relative(canonicalRoot, canonicalTarget); + if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) throw helperError('HELPER_REPARSE'); + let cursor = canonicalRoot; + for (const part of inside.split(sep)) { + cursor = join(cursor, part); + const stats = await lstat(cursor, { bigint: true }).catch(() => { throw helperError('HELPER_REPARSE'); }); + if (stats.isSymbolicLink() || (!stats.isDirectory() && cursor !== canonicalTarget)) throw helperError('HELPER_REPARSE'); + } + const stats = await lstat(canonicalTarget, { bigint: true }).catch(() => { throw helperError('HELPER_REPARSE'); }); + return { path: canonicalTarget, identity: { dev: stats.dev, ino: stats.ino, size: stats.size, nlink: stats.nlink } }; +}; + +const authenticateWindowsAuthorityHelper = async ( + directory = helperDirectory(), + beforeOpenForTest?: () => void | Promise, + expectedPublisher = embeddedExpectedPublisher(), + expectedSignerPins = embeddedExpectedSignerPins(), + nativeLoadFaultForTest?: 'barrier-before-module-load-swap' | 'barrier-before-module-load-write' + | 'barrier-before-module-load-delete', + allowUnsignedBootstrapForValidation = expectedPublisher === undefined && directory === helperDirectory(), +): Promise => { + if (!isAbsolute(directory) || directory.indexOf(':', 2) >= 0) throw helperError('MANIFEST'); + const executableProof = await proveCanonicalTree(directory, join(directory, HELPER_NAME)); + const launcherProof = await proveCanonicalTree(directory, join(directory, LAUNCHER_NAME)); + const bootstrapProof = await proveCanonicalTree(directory, join(directory, BOOTSTRAP_NAME)); + const manifestProof = await proveCanonicalTree(directory, join(directory, HELPER_MANIFEST_NAME)); + await beforeOpenForTest?.(); + let executableHandle: FileHandle | undefined; + let launcherHandle: FileHandle | undefined; + let bootstrapHandle: FileHandle | undefined; + let manifestHandle: FileHandle | undefined; + try { + manifestHandle = await open(manifestProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('MANIFEST'); }); + const manifestStats = await manifestHandle.stat({ bigint: true }); + if (!manifestStats.isFile() || manifestStats.dev !== manifestProof.identity.dev || manifestStats.ino !== manifestProof.identity.ino + || manifestStats.nlink !== 1n || manifestStats.size <= 1n + || manifestStats.size > BigInt(HELPER_MANIFEST_BYTES)) throw helperError('MANIFEST'); + const manifest = parseWindowsAuthorityHelperManifestForTest( + await readHeldExactly(manifestHandle, Number(manifestStats.size), 'MANIFEST'), + ); + if (expectedPublisher + ? manifest.trust !== 'production-signed' || manifest.publisher !== expectedPublisher + : manifest.trust !== 'unsigned-validation' || manifest.publisher !== null) throw helperError('MANIFEST'); + if (expectedPublisher && JSON.stringify(manifest.signerPins) !== JSON.stringify(expectedSignerPins)) { + throw helperError('MANIFEST'); + } + executableHandle = await open(executableProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('HELPER_OPEN'); }); + const before = await executableHandle.stat({ bigint: true }); + if (!before.isFile() || before.dev !== executableProof.identity.dev || before.ino !== executableProof.identity.ino + || before.nlink !== 1n || before.size !== BigInt(manifest.size)) throw helperError('HELPER_IDENTITY'); + const bytes = await readHeldExactly(executableHandle, manifest.size, 'HELPER_HASH'); + inspectWindowsAuthorityHelperPeForTest(bytes); + if (createHash('sha256').update(bytes).digest('hex') !== manifest.sha256) throw helperError('HELPER_HASH'); + const after = await executableHandle.stat({ bigint: true }); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size + || before.nlink !== after.nlink) throw helperError('HELPER_IDENTITY'); + launcherHandle = await open(launcherProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('HELPER_OPEN'); }); + const launcherBefore = await launcherHandle.stat({ bigint: true }); + if (!launcherBefore.isFile() || launcherBefore.dev !== launcherProof.identity.dev + || launcherBefore.ino !== launcherProof.identity.ino || launcherBefore.nlink !== 1n + || launcherBefore.size !== BigInt(manifest.launcher.size) + || manifest.launcher.architecture !== process.arch) throw helperError('HELPER_IDENTITY'); + const launcherBytes = await readHeldExactly(launcherHandle, manifest.launcher.size, 'HELPER_HASH'); + inspectWindowsNativeLauncherPeForTest(launcherBytes, manifest.launcher.architecture); + if (createHash('sha256').update(launcherBytes).digest('hex') !== manifest.launcher.sha256) { + throw helperError('HELPER_HASH'); + } + const launcherAfter = await launcherHandle.stat({ bigint: true }); + if (launcherAfter.dev !== launcherBefore.dev || launcherAfter.ino !== launcherBefore.ino + || launcherAfter.size !== launcherBefore.size || launcherAfter.nlink !== launcherBefore.nlink) { + throw helperError('HELPER_IDENTITY'); + } + bootstrapHandle = await open(bootstrapProof.path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW) + .catch(() => { throw helperError('HELPER_OPEN'); }); + const bootstrapBefore = await bootstrapHandle.stat({ bigint: true }); + if (!bootstrapBefore.isFile() || bootstrapBefore.dev !== bootstrapProof.identity.dev + || bootstrapBefore.ino !== bootstrapProof.identity.ino || bootstrapBefore.nlink !== 1n + || bootstrapBefore.size !== BigInt(manifest.bootstrap.size)) throw helperError('HELPER_IDENTITY'); + const bootstrapBytes = await readHeldExactly(bootstrapHandle, manifest.bootstrap.size, 'HELPER_HASH'); + inspectWindowsNativeLauncherPeForTest(bootstrapBytes, manifest.bootstrap.architecture); + if (createHash('sha256').update(bootstrapBytes).digest('hex') !== manifest.bootstrap.sha256) { + throw helperError('HELPER_HASH'); + } + const bootstrapAfter = await bootstrapHandle.stat({ bigint: true }); + if (bootstrapAfter.dev !== bootstrapBefore.dev || bootstrapAfter.ino !== bootstrapBefore.ino + || bootstrapAfter.size !== bootstrapBefore.size || bootstrapAfter.nlink !== bootstrapBefore.nlink) { + throw helperError('HELPER_IDENTITY'); + } + // The kernel SystemRoot namespace selects and the OS-serviced policy + // authenticates the verifier without consulting process environment roots. + // Its held bootstrap lease spans N-API initialization and launcher loading. + const releaseBootstrapAuthority = await acquireBootstrapPackageAuthority( + bootstrapProof.path, + manifest.bootstrap, + allowUnsignedBootstrapForValidation, + { dev: bootstrapBefore.dev.toString(), ino: bootstrapBefore.ino.toString() }, + bootstrapHandle, + ); + let bootstrap: WindowsNativeBootstrap; + let nativeLauncher: WindowsNativeLauncher; + try { + if (require.cache[bootstrapProof.path]) throw helperError('HELPER_OPEN'); + bootstrap = require(bootstrapProof.path) as WindowsNativeBootstrap; + if (!bootstrap || typeof bootstrap.loadVerifiedModule !== 'function') throw helperError('HELPER_OPEN'); + nativeLauncher = bootstrap.loadVerifiedModule({ + path: launcherProof.path, + size: manifest.launcher.size, + sha256: manifest.launcher.sha256, + production: manifest.launcher.trust === 'production-signed', + publisher: manifest.launcher.publisher, + signerCertificateSha256: manifest.launcher.signerCertificateSha256, + signerSpkiSha256: manifest.launcher.signerSpkiSha256, + fault: nativeLoadFaultForTest ?? null, + }); + } catch { throw helperError('HELPER_IDENTITY'); } + finally { await releaseBootstrapAuthority(); } + if (!nativeLauncher || typeof nativeLauncher.launch !== 'function') throw helperError('HELPER_IDENTITY'); + return { executable: executableProof.path, executableHandle, launcherHandle, bootstrapHandle, manifestHandle, manifest, + launcher: nativeLauncher }; + } catch (error) { + await executableHandle?.close().catch(() => undefined); + await launcherHandle?.close().catch(() => undefined); + await bootstrapHandle?.close().catch(() => undefined); + await manifestHandle?.close().catch(() => undefined); + throw error; + } +}; + +export const authenticateWindowsAuthorityHelperForTest = authenticateWindowsAuthorityHelper; + +const spawnBroker = ( + helper: AuthenticatedWindowsAuthorityHelper, + injectedStage?: WindowsAuthorityCompileStage, + transportFault?: 'stderr', + imageFault?: 'process-image', + nativeFault?: string, +): BrokerChild => { + const native = helper.launcher.launch({ + path: helper.executable, + size: helper.manifest.size, + sha256: helper.manifest.sha256, + production: helper.manifest.trust === 'production-signed', + publisher: helper.manifest.publisher, + signerCertificateSha256: helper.manifest.signerCertificateSha256, + signerSpkiSha256: helper.manifest.signerSpkiSha256, + // Fixed test-only enums are interpreted by the native boundary; no path, + // capability, challenge, or secret is placed in argv or the child environment. + fault: nativeFault ?? injectedStage ?? transportFault ?? imageFault ?? null, + }); + return new NativeBrokerChild(helper.launcher, native); +}; + +class NativeBrokerChild extends EventEmitter implements BrokerChild { + readonly stdin: Writable; + readonly stdout: Readable; + readonly stderr: Readable; + exitCode: number | null = null; + killed = false; + readonly imageVolumeSerial: string; + readonly imageFileId128: string; + private poll: NodeJS.Timeout | undefined; + private outputEnded = 0; + private closed = false; + + constructor(private readonly launcher: WindowsNativeLauncher, private readonly native: NativeLaunchLease) { + super(); + this.imageVolumeSerial = native.volumeSerial; + this.imageFileId128 = native.fileId128; + this.stdin = createWriteStream('', { fd: native.stdinFd, autoClose: false }); + this.stdout = createReadStream('', { fd: native.stdoutFd, autoClose: false }); + this.stderr = createReadStream('', { fd: native.stderrFd, autoClose: false }); + this.stdin.once('finish', () => { + try { this.launcher.closeInput(this.native.lease); } catch { /* process exit owns cleanup */ } + }); + const ended = () => { this.outputEnded += 1; this.finishIfReady(); }; + this.stdout.once('end', ended); + this.stderr.once('end', ended); + this.poll = setInterval(() => this.pollExit(), 20); + this.poll.unref(); + } + + private pollExit(): void { + if (this.closed) return; + try { + const code = this.launcher.status(this.native.lease); + if (code !== null) { + this.exitCode = code; + if (this.poll) clearInterval(this.poll); + this.poll = undefined; + this.finishIfReady(); + } + } catch { + if (this.poll) clearInterval(this.poll); + this.poll = undefined; + this.emit('error', new Error('Windows native launcher status failed')); + } + } + + private finishIfReady(): void { + if (this.closed || this.exitCode === null || this.outputEnded !== 2) return; + this.closed = true; + try { this.launcher.close(this.native.lease); } catch { /* fixed close path */ } + this.emit('close', this.exitCode); + } + + kill(): boolean { + if (this.closed || this.killed) return false; + this.killed = true; + try { this.launcher.terminate(this.native.lease); return true; } catch { return false; } + } + + unref(): void { this.poll?.unref(); } +} + +class WindowsAuthorityError extends Error { + constructor(readonly reason: WindowsAuthorityReason, readonly scenario: number) { + super(`Verified update cache authority inspection failed [win-authority:${reason}:${scenario}]`); + } +} + +export type WindowsAuthorityBootstrapFailureKind = + | 'SPAWN_ERROR' + | 'EXIT_NO_OUTPUT' + | 'EXIT_AFTER_OUTPUT' + | 'TIMEOUT' + | 'MALFORMED_OUTPUT' + | 'EXTRA_OUTPUT' + | 'STAGE_CHANNEL' + | 'WRITE_ERROR'; + +export class WindowsAuthorityBootstrapError extends WindowsAuthorityError { + readonly stage: WindowsAuthorityCompileStage; + + constructor(readonly kind: WindowsAuthorityBootstrapFailureKind, stageIndex: number) { + super('compile_load', stageIndex); + this.stage = WINDOWS_AUTHORITY_COMPILE_STAGES[stageIndex] ?? 'TRANSPORT_SPAWN'; + } +} + +const authorityError = (reason: WindowsAuthorityReason, scenario: number): WindowsAuthorityError => + new WindowsAuthorityError(reason, scenario); + +const abortError = (): Error => Object.assign(new Error('Windows authority request aborted'), { name: 'AbortError' }); + +const throwIfAborted = (signal?: AbortSignal): void => { + if (signal?.aborted) throw abortError(); +}; + +const hasExactKeys = (value: Record, keys: readonly string[]): boolean => + Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)); + +const parseFailure = (value: unknown, expectedId?: string): Error | undefined => { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Record; + const keys = expectedId === undefined + ? ['version', 'type', 'reason', 'scenario'] + : ['version', 'type', 'id', 'reason', 'scenario']; + if (candidate.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION || candidate.type !== 'error' + || !hasExactKeys(candidate, keys) || (expectedId !== undefined && candidate.id !== expectedId) + || typeof candidate.reason !== 'string' || !reasonCodes.has(candidate.reason) + || !Number.isInteger(candidate.scenario) || Number(candidate.scenario) < 0 || Number(candidate.scenario) > 99) { + return undefined; + } + return authorityError(candidate.reason as WindowsAuthorityReason, Number(candidate.scenario)); +}; + +const parseInspection = ( + value: unknown, + directory: boolean, + hashes: boolean, +): WindowsPrivatePathInspection | WindowsHeldVerification | undefined => { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as Record; + if (candidate.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION + || !/^[a-f0-9]{16}$/.test(String(candidate.volumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(candidate.fileId128)) + || candidate.directory !== directory + || !/^(0|[1-9]\d*)$/.test(String(candidate.links)) + || !/^(0|[1-9]\d*)$/.test(String(candidate.size)) + || (!hashes && !directory && BigInt(String(candidate.size)) > BigInt(BROKER_SETUP_FILE_BYTES)) + || !/^[a-f0-9]{8}$/.test(String(candidate.reparseTag)) + || candidate.reparseTag !== '00000000' + || !/^S-1-(?:\d+-){1,14}\d+$/.test(String(candidate.ownerSid)) + || candidate.daclProtected !== true + || !/^(0|[1-9]\d*)$/.test(String(candidate.aceCount)) + || candidate.inheritedWriteAces !== '0' + || candidate.broadWriteAces !== '0' + || (hashes && (!/^[a-f0-9]{64}$/.test(String(candidate.sha256)) + || !/^[a-f0-9]{40}$/.test(String(candidate.sha1))))) return undefined; + const inspection: WindowsPrivatePathInspection = { + identity: { + platform: 'win32', + volumeSerial: String(candidate.volumeSerial), + fileId128: String(candidate.fileId128), + }, + directory, + links: String(candidate.links), + size: String(candidate.size), + reparseTag: String(candidate.reparseTag), + ownerSid: String(candidate.ownerSid), + daclProtected: true, + aceCount: String(candidate.aceCount), + inheritedWriteAces: '0', + broadWriteAces: '0', + }; + return hashes ? { + ...inspection, + sha256: String(candidate.sha256), + sha1: String(candidate.sha1), + } : inspection; +}; + +type BrokerRequestOperation = BrokerOperation | 'hold' | 'continue' | 'read' | 'verify' | 'close' | 'fault-stderr'; +// After the authenticated image/challenge exchange, the persistent process +// accepts only four-byte-length-prefixed strict-UTF-8 versioned request frames. Node +// permits one in-flight frame at a time; a held capability owns the FIFO lease +// until close, so its native handle cannot be confused with another entry. +interface BrokerRequestFrame { + version: typeof WINDOWS_AUTHORITY_PROTOCOL_VERSION; + type: 'request'; + id: string; + operation: BrokerRequestOperation; + purpose: BrokerPurpose; + path: string | null; + directory: boolean | null; + expectedBytes: number | null; + expectedVolumeSerial: string | null; + expectedFileId128: string | null; + expectedSha256: string | null; + challenge: string | null; + barrier: string | null; + offset: number | null; + length: number | null; +} + +interface FrameWaiter { + resolve(value: Record): void; + reject(error: Error): void; + timer: NodeJS.Timeout; + signal?: AbortSignal; + abort?: () => void; +} + +interface LockedArtifactProcess { + session: WindowsAuthoritySession; + exited: Promise; + challenge: string; + heldId: string; + purpose: BrokerPurpose; + release(): void; + timeout: NodeJS.Timeout; +} + +let brokerSession: WindowsAuthoritySession | undefined; +let brokerStartup: Promise | undefined; +let compileCount = 0; +let requestCount = 0; +let restartCount = 0; +let activeProcessCount = 0; +let lastClosedHeldId: string | undefined; +const brokerChildren = new Set(); + +const encodeProtocolFrame = (value: string): Buffer => { + const bytes = Buffer.from(value, 'utf8'); + if (bytes.length <= 0 || bytes.length > BROKER_REQUEST_LINE_BYTES) throw authorityError('request_protocol', 1); + const prefix = Buffer.allocUnsafe(4); + prefix.writeUInt32BE(bytes.length); + return Buffer.concat([prefix, bytes]); +}; + +const decodeProtocolChunk = (buffered: Buffer, chunk: Buffer): { + buffered: Buffer; + frames: readonly Buffer[]; +} => { + let combined = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]); + const frames: Buffer[] = []; + while (combined.length >= 4) { + const length = combined.readUInt32BE(0); + if (length <= 0 || length > BROKER_PROTOCOL_LINE_BYTES) throw authorityError('output_bound', 17); + if (combined.length < 4 + length) break; + frames.push(combined.subarray(4, 4 + length)); + combined = combined.subarray(4 + length); + } + if (combined.length > BROKER_PROTOCOL_LINE_BYTES + 4) throw authorityError('output_bound', 17); + return { buffered: Buffer.from(combined), frames }; +}; + +class WindowsAuthoritySession { + readonly exited: Promise; + private terminalError: Error | undefined; + private buffered: Buffer = Buffer.alloc(0); + private waiter: FrameWaiter | undefined; + private stderrBytes = 0; + private stderrBuffered = ''; + private bootstrapStages: WindowsAuthorityCompileStage[] = WINDOWS_AUTHORITY_COMPILE_STAGES.slice(0, 4); + private bootstrapReady = false; + private bootstrapResolve!: () => void; + private readonly bootstrapCompleted = new Promise(resolve => { this.bootstrapResolve = resolve; }); + private inputBytes = 0; + private outputBytes = 0; + private frames = 0; + private closing = false; + + constructor( + readonly child: BrokerChild, + private readonly sharedQueue = true, + private readonly helper?: AuthenticatedWindowsAuthorityHelper, + ) { + activeProcessCount++; + brokerChildren.add(child); + child.stdout.on('data', (chunk: Buffer) => this.consume(chunk)); + child.stderr.on('data', (chunk: Buffer) => this.consumeBootstrapStage(chunk)); + child.stdin.on('error', () => this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('WRITE_ERROR'))); + child.on('error', () => this.invalidate(this.bootstrapReady + ? authorityError('process_exit', 19) : this.bootstrapError('SPAWN_ERROR'))); + this.exited = new Promise(resolve => child.once('close', code => { + activeProcessCount--; + brokerChildren.delete(child); + const clean = this.closing && code === 0 && this.stderrBuffered === '' && this.buffered.length === 0; + this.fail(clean ? authorityError('clean_shutdown', 15) + : this.bootstrapReady ? authorityError('process_exit', 19) + : this.bootstrapError(this.outputBytes === 0 ? 'EXIT_NO_OUTPUT' : 'EXIT_AFTER_OUTPUT'), false); + if (brokerSession === this) brokerSession = undefined; + void this.helper?.executableHandle.close().catch(() => undefined); + void this.helper?.launcherHandle.close().catch(() => undefined); + void this.helper?.bootstrapHandle.close().catch(() => undefined); + void this.helper?.manifestHandle.close().catch(() => undefined); + resolve(); + })); + child.unref(); + (child.stdin as typeof child.stdin & { unref?(): void }).unref?.(); + (child.stdout as typeof child.stdout & { unref?(): void }).unref?.(); + (child.stderr as typeof child.stderr & { unref?(): void }).unref?.(); + } + + private bootstrapError(kind: WindowsAuthorityBootstrapFailureKind = 'EXIT_NO_OUTPUT'): WindowsAuthorityBootstrapError { + return new WindowsAuthorityBootstrapError(kind, this.bootstrapStages.length - 1); + } + + private consumeBootstrapStage(chunk: Buffer): void { + if (this.terminalError) return; + this.stderrBytes += chunk.length; + if (this.stderrBytes > BROKER_OUTPUT_BYTES || this.bootstrapReady) { + return this.invalidate(authorityError(this.stderrBytes > BROKER_OUTPUT_BYTES ? 'output_bound' : 'stdio_protocol', + this.stderrBytes > BROKER_OUTPUT_BYTES ? 17 : 16)); + } + this.stderrBuffered += chunk.toString('ascii'); + while (this.stderrBuffered.includes('\n')) { + const newline = this.stderrBuffered.indexOf('\n'); + const line = this.stderrBuffered.slice(0, newline).replace(/\r$/, ''); + this.stderrBuffered = this.stderrBuffered.slice(newline + 1); + const match = /^PROPR_BOOTSTRAP (\d{2}) ([A-Z_]+)$/.exec(line); + const expectedIndex = this.bootstrapStages.length; + const expectedStage = WINDOWS_AUTHORITY_COMPILE_STAGES[expectedIndex]; + if (!match || Number(match[1]) !== expectedIndex || match[2] !== expectedStage) { + return this.invalidate(this.bootstrapError('STAGE_CHANNEL')); + } + this.bootstrapStages.push(expectedStage); + if (expectedStage === 'READY') this.bootstrapResolve(); + } + if (this.stderrBuffered.length > 128) this.invalidate(this.bootstrapError('STAGE_CHANNEL')); + } + + async requireBootstrapReady(timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + this.bootstrapCompleted, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = this.bootstrapError('TIMEOUT'); + this.invalidate(error); + reject(error); + }, timeoutMs); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (this.terminalError || this.stderrBuffered !== '' + || this.bootstrapStages.length !== WINDOWS_AUTHORITY_COMPILE_STAGES.length) { + throw this.terminalError ?? this.bootstrapError('STAGE_CHANNEL'); + } + this.bootstrapReady = true; + } + + currentBootstrapStage(): WindowsAuthorityCompileStage { + return this.bootstrapStages[this.bootstrapStages.length - 1]; + } + + private consume(chunk: Buffer): void { + if (this.terminalError) return; + this.outputBytes += chunk.length; + if (this.outputBytes > BROKER_MAX_OUTPUT_BYTES) return this.invalidate(authorityError('output_bound', 17)); + let decoded: ReturnType; + try { decoded = decodeProtocolChunk(this.buffered, chunk); } catch (error) { + return this.invalidate(this.bootstrapReady + ? (error instanceof Error ? error : authorityError('stdio_protocol', 16)) + : this.bootstrapError('MALFORMED_OUTPUT')); + } + this.buffered = decoded.buffered; + for (const frame of decoded.frames) { + if (!this.waiter) return this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('EXTRA_OUTPUT')); + let value: unknown; + try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)); } catch { + return this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('MALFORMED_OUTPUT')); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return this.invalidate(this.bootstrapReady + ? authorityError('stdio_protocol', 16) : this.bootstrapError('MALFORMED_OUTPUT')); + } + const waiter = this.waiter; + this.waiter = undefined; + clearTimeout(waiter.timer); + if (waiter.signal && waiter.abort) waiter.signal.removeEventListener('abort', waiter.abort); + waiter.resolve(value as Record); + } + } + + private fail(error: Error, kill: boolean): void { + this.terminalError ??= error; + if (this.waiter) { + const waiter = this.waiter; + this.waiter = undefined; + clearTimeout(waiter.timer); + if (waiter.signal && waiter.abort) waiter.signal.removeEventListener('abort', waiter.abort); + waiter.reject(this.terminalError); + } + if (this.sharedQueue) rejectBrokerQueue(this.terminalError); + if (kill && !this.child.killed) this.child.kill(); + } + + invalidate(error: Error): void { this.fail(error, true); } + + async receive(timeoutMs: number, signal?: AbortSignal, startup = false): Promise> { + throwIfAborted(signal); + if (this.terminalError) throw this.terminalError; + if (this.waiter) throw authorityError('stdio_protocol', 16); + return new Promise((resolve, reject) => { + const waiter: FrameWaiter = { + resolve, + reject, + signal, + timer: setTimeout(() => this.invalidate(startup + ? this.bootstrapError('TIMEOUT') : authorityError('timeout', 18)), timeoutMs), + }; + if (signal) { + waiter.abort = () => this.invalidate(abortError()); + signal.addEventListener('abort', waiter.abort, { once: true }); + } + this.waiter = waiter; + }); + } + + private async writeChunk(value: string | Buffer): Promise { + if (this.terminalError) throw this.terminalError; + if (this.child.stdin.write(value)) return; + await new Promise((resolve, reject) => { + const cleanup = () => { + this.child.stdin.removeListener('drain', drained); + this.child.stdin.removeListener('error', failed); + }; + const drained = () => { cleanup(); resolve(); }; + const failed = () => { cleanup(); reject(this.terminalError ?? authorityError('stdio_protocol', 16)); }; + this.child.stdin.once('drain', drained); + this.child.stdin.once('error', failed); + }); + } + + async write(value: string | BrokerRequestFrame): Promise { + if (this.terminalError) throw this.terminalError; + const frame = encodeProtocolFrame(typeof value === 'string' ? value : JSON.stringify(value)); + this.inputBytes += frame.length; + if (this.inputBytes > BROKER_MAX_INPUT_BYTES || ++this.frames > BROKER_MAX_FRAMES) { + this.invalidate(authorityError('output_bound', 17)); + throw authorityError('output_bound', 17); + } + await this.writeChunk(frame); + } + + async writeRawForTest(chunks: readonly Buffer[]): Promise { + if (this.terminalError || chunks.length === 0 + || chunks.some(chunk => chunk.length === 0 || chunk.length > BROKER_REQUEST_LINE_BYTES + 4)) { + throw authorityError('request_protocol', 1); + } + for (const chunk of chunks) await this.writeChunk(chunk); + } + + async exchange(frame: BrokerRequestFrame, signal?: AbortSignal): Promise> { + const response = this.receive(BROKER_TIMEOUT_MS, signal); + await this.write(frame); + const value = await response; + requestCount++; + const failure = parseFailure(value, frame.id) ?? parseFailure(value); + if (failure) throw failure; + if (value.id !== frame.id) { + this.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('stdio_protocol', 16); + } + return value; + } + + async shutdown(): Promise { + if (this.child.exitCode !== null) return; + this.closing = true; + this.child.stdin.end(); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + this.exited, + new Promise(resolve => { + timer = setTimeout(() => { this.child.kill(); resolve(); }, BROKER_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } +} + +const exactKeys = (value: Record, keys: readonly string[]): boolean => hasExactKeys(value, keys); +const RESPONSE_INSPECTION_KEYS = Object.freeze([...INSPECTION_KEYS, 'id', 'challenge'] as const); + +const requestFrame = (operation: BrokerRequestOperation, values: Partial = {}): BrokerRequestFrame => ({ + version: WINDOWS_AUTHORITY_PROTOCOL_VERSION, + type: 'request', + id: randomBytes(16).toString('hex'), + operation, + purpose: 'setup', + path: null, + directory: null, + expectedBytes: null, + expectedVolumeSerial: null, + expectedFileId128: null, + expectedSha256: null, + challenge: null, + barrier: null, + offset: null, + length: null, + ...values, +}); + +interface StartBrokerOptions { + injectedStage?: WindowsAuthorityCompileStage; + countCompilation?: boolean; + transportFault?: 'stderr'; + imageFault?: 'process-image'; + helperDirectory?: string; + expectedPublisher?: string; + nativeFault?: string; + allowUnsignedBootstrapForValidation?: boolean; +} + +const startBroker = async (options: StartBrokerOptions = {}): Promise => { + if (options.injectedStage && WINDOWS_AUTHORITY_COMPILE_STAGES.slice(0, 4).includes(options.injectedStage)) { + throw helperError(options.injectedStage); + } + const helper = await authenticateWindowsAuthorityHelper( + options.helperDirectory, + undefined, + options.expectedPublisher ?? embeddedExpectedPublisher(), + embeddedExpectedSignerPins(), + undefined, + options.allowUnsignedBootstrapForValidation, + ); + let child: BrokerChild; + try { + child = spawnBroker(helper, options.injectedStage, options.transportFault, options.imageFault, options.nativeFault); + } catch { + await helper.executableHandle.close().catch(() => undefined); + await helper.launcherHandle.close().catch(() => undefined); + await helper.bootstrapHandle.close().catch(() => undefined); + await helper.manifestHandle.close().catch(() => undefined); + throw new WindowsAuthorityBootstrapError('SPAWN_ERROR', WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf('TRANSPORT_SPAWN')); + } + if (options.countCompilation !== false) { + compileCount++; + if (compileCount > 1) restartCount++; + } + const session = new WindowsAuthoritySession(child, options.countCompilation !== false, helper); + const challenge = randomBytes(16).toString('hex'); + const startupDeadline = Date.now() + BROKER_STARTUP_TIMEOUT_MS; + const readyPromise = session.receive(BROKER_STARTUP_TIMEOUT_MS, undefined, true); + try { + await session.write(JSON.stringify({ + version: WINDOWS_AUTHORITY_PROTOCOL_VERSION, + type: 'start', + challenge, + protocol: 'propr-windows-authority-v1', + })); + } catch (error) { + session.invalidate(error instanceof Error ? error : authorityError('compile_load', 0)); + throw error; + } + const ready = await readyPromise; + const failure = parseFailure(ready); + if (failure) { + session.invalidate(failure); + throw failure; + } + await session.requireBootstrapReady(Math.max(1, startupDeadline - Date.now())); + if (!exactKeys(ready, ['version', 'type', 'challenge', 'protocol', 'maxRequestBytes', 'nativeSmoke', 'compileCount', + 'imageVolumeSerial', 'imageFileId128', 'imageSha256']) + || ready.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION || ready.type !== 'ready' + || ready.challenge !== challenge || ready.protocol !== 'propr-windows-authority-v1' + || ready.maxRequestBytes !== BROKER_REQUEST_LINE_BYTES || ready.nativeSmoke !== true || ready.compileCount !== 1 + || !/^[a-f0-9]{16}$/.test(String(ready.imageVolumeSerial)) + || !/^[a-f0-9]{32}$/.test(String(ready.imageFileId128)) + || ready.imageVolumeSerial !== child.imageVolumeSerial || ready.imageFileId128 !== child.imageFileId128 + || ready.imageSha256 !== helper.manifest.sha256) { + const error = new WindowsAuthorityBootstrapError('MALFORMED_OUTPUT', WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf('READY')); + session.invalidate(error); + throw error; + } + return session; +}; + +const compileStageFromError = (error: unknown): WindowsAuthorityCompileStage => { + if (error instanceof WindowsAuthorityError && error.reason === 'compile_load' + && error.scenario >= 0 && error.scenario < WINDOWS_AUTHORITY_COMPILE_STAGES.length) { + return WINDOWS_AUTHORITY_COMPILE_STAGES[error.scenario]; + } + return 'TRANSPORT_SPAWN'; +}; + +const runWindowsAuthorityCompileProbe = async (options: StartBrokerOptions = {}): Promise => { + let session: WindowsAuthoritySession | undefined; + try { + session = await startBroker({ ...options, countCompilation: false }); + return 'READY'; + } catch (error) { + return compileStageFromError(error); + } finally { + await session?.shutdown(); + } +}; + +/** Hosted smoke of the exact build-produced executable and READY handshake. */ +export const probeWindowsAuthorityCompile = (): Promise => + runWindowsAuthorityCompileProbe(); + +export const probePackagedWindowsAuthorityHelper = (directory: string): Promise => { + if (!isAbsolute(directory)) return Promise.reject(helperError('MANIFEST')); + const expectedPublisher = process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1' + ? process.env.PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY + : undefined; + if (process.env.PROPR_DESKTOP_PRODUCTION_RELEASE === '1' && !expectedPublisher) { + return Promise.reject(helperError('MANIFEST')); + } + return runWindowsAuthorityCompileProbe({ + helperDirectory: directory, + expectedPublisher, + allowUnsignedBootstrapForValidation: process.env.PROPR_DESKTOP_PRODUCTION_RELEASE !== '1', + }); +}; + +/** Native-test-only corrupt-output classification; no compiler diagnostics leave the build boundary. */ +export const probeWindowsAuthorityCompileFailureForTest = (): Promise => + Promise.resolve('BUILD_OUTPUT'); + +/** Native-test-only failure injection at each fixed startup boundary. */ +export const probeWindowsAuthorityBootstrapStageForTest = (stage: WindowsAuthorityCompileStage): Promise => + runWindowsAuthorityCompileProbe({ injectedStage: stage }); + +export const probeWindowsAuthorityProcessImageMismatchForTest = (): Promise => + runWindowsAuthorityCompileProbe({ imageFault: 'process-image' }); + +export const probeWindowsAuthorityNativeBoundaryForTest = ( + fault: 'barrier-after-hash-delete' | 'barrier-after-hash-swap' | 'barrier-after-hash-write' + | 'barrier-before-create-delete' | 'barrier-before-create-swap' | 'barrier-before-create-write' + | 'barrier-after-process-delete' | 'barrier-after-process-swap' | 'barrier-after-process-write' + | 'extra-child' | 'job-assignment' | 'parent-image-proof' | 'pipe-substitution', +): Promise => runWindowsAuthorityCompileProbe({ nativeFault: fault }); + +/** Native-test-only startup failure against the exact compiled production child. */ +export const probeWindowsAuthorityStartupFailureForTest = async (): Promise => { + const helper = await authenticateWindowsAuthorityHelper(); + const session = new WindowsAuthoritySession(spawnBroker(helper), false, helper); + try { + const response = session.receive(BROKER_STARTUP_TIMEOUT_MS, undefined, true); + await session.write(JSON.stringify({ + version: WINDOWS_AUTHORITY_PROTOCOL_VERSION, + type: 'start', + challenge: randomBytes(16).toString('hex'), + protocol: 'invalid-protocol', + })); + await response; + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (error instanceof WindowsAuthorityError && error.reason === 'ready_protocol') return 'ready_protocol'; + if (error instanceof WindowsAuthorityError && error.reason === 'compile_load' + && error.scenario === WINDOWS_AUTHORITY_COMPILE_STAGES.indexOf('READY')) return 'ready_protocol'; + if (error instanceof WindowsAuthorityBootstrapError + && error.stage === 'PROTOCOL_INIT') return 'ready_protocol'; + throw error; + } finally { + await session.shutdown(); + } +}; + +/** Native-test-only live transport faults with short local deadlines and fixed diagnostics. */ +export const injectWindowsAuthorityTransportFaultForTest = async ( + kind: 'stderr' | 'slowloris' | 'timeout', +): Promise => { + const session = await startBroker({ countCompilation: false, transportFault: kind === 'stderr' ? 'stderr' : undefined }); + try { + if (kind === 'stderr') { + await session.exchange(requestFrame('fault-stderr')); + } else { + const response = session.receive(50); + if (kind === 'slowloris') await session.writeRawForTest([Buffer.from([0, 0, 0, 100, 0x7b])]); + await response; + } + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (error instanceof WindowsAuthorityError) return error.reason; + throw error; + } finally { await session.shutdown(); } +}; + +const getBroker = async (): Promise => { + if (brokerSession) return brokerSession; + brokerStartup ??= startBroker().then(session => { + brokerSession = session; + return session; + }).finally(() => { brokerStartup = undefined; }); + return brokerStartup; +}; + +const retryableInfrastructureError = (error: unknown): boolean => error instanceof WindowsAuthorityError + && ['ready_protocol', 'stdio_protocol', 'output_bound', 'timeout', 'process_exit', 'clean_shutdown'].includes(error.reason); + +const withRestartOnce = async (work: (session: WindowsAuthoritySession) => Promise): Promise => { + let first: unknown; + try { return await work(await getBroker()); } catch (error) { first = error; } + if (!retryableInfrastructureError(first)) throw first; + if (brokerSession) brokerSession.invalidate(first as Error); + brokerSession = undefined; + return work(await getBroker()); +}; + +interface QueueEntry { signal?: AbortSignal; resolve(release: () => void): void; reject(error: Error): void; abort?: () => void } +const brokerQueue: QueueEntry[] = []; +let brokerLeaseActive = false; + +const rejectBrokerQueue = (error: Error): void => { + for (const entry of brokerQueue.splice(0)) { + if (entry.signal && entry.abort) entry.signal.removeEventListener('abort', entry.abort); + entry.reject(error); + } +}; + +const dispatchLease = (): void => { + if (brokerLeaseActive) return; + const entry = brokerQueue.shift(); + if (!entry) return; + if (entry.signal?.aborted) { + entry.reject(abortError()); + dispatchLease(); + return; + } + brokerLeaseActive = true; + if (entry.signal && entry.abort) entry.signal.removeEventListener('abort', entry.abort); + let released = false; + entry.resolve(() => { + if (released) return; + released = true; + brokerLeaseActive = false; + dispatchLease(); + }); +}; + +const acquireLease = (signal?: AbortSignal): Promise<() => void> => { + throwIfAborted(signal); + if (brokerQueue.length >= BROKER_MAX_QUEUE_ENTRIES) return Promise.reject(authorityError('output_bound', 17)); + return new Promise((resolve, reject) => { + const entry: QueueEntry = { signal, resolve, reject }; + if (signal) { + entry.abort = () => { + const index = brokerQueue.indexOf(entry); + if (index >= 0) brokerQueue.splice(index, 1); + reject(abortError()); + }; + signal.addEventListener('abort', entry.abort, { once: true }); + } + brokerQueue.push(entry); + dispatchLease(); + }); +}; + +const runBroker = async ( + operation: BrokerOperation, + path: string, + directory: boolean, + signal?: AbortSignal, +): Promise => { + const release = await acquireLease(signal); + try { + return await withRestartOnce(async session => { + const request = requestFrame(operation, { purpose: 'setup', path, directory }); + const value = await session.exchange(request, signal); + const inspected = parseInspection(value, directory, false); + if (!inspected || value.type !== 'inspection' || value.challenge !== '' + || !exactKeys(value, RESPONSE_INSPECTION_KEYS)) { + session.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('stdio_protocol', 16); + } + return inspected; + }); + } finally { release(); } +}; + +export const inspectWindowsPrivatePath = ( + path: string, + directory = false, + signal?: AbortSignal, +): Promise => runBroker('inspect', path, directory, signal); + +export const ensureWindowsPrivateDirectory = ( + path: string, + signal?: AbortSignal, +): Promise => runBroker('ensure-directory', path, true, signal); + +export const protectWindowsPrivateDirectory = ( + path: string, + signal?: AbortSignal, +): Promise => runBroker('protect-directory', path, true, signal); + +export const protectWindowsPrivateFile = ( + path: string, + signal?: AbortSignal, +): Promise => runBroker('protect-file', path, false, signal); + +const openWindowsLockedArtifactAttempt = async ( + path: string, + expectedBytes: number, + expectedIdentity: WindowsFileIdentity, + expectedSha256: string | undefined, + beforeOpenForTest?: () => Promise, + signal?: AbortSignal, + retry = true, +): Promise => { + if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || expectedBytes > BROKER_ARTIFACT_BYTES + || !/^[a-f0-9]{16}$/.test(expectedIdentity.volumeSerial) + || !/^[a-f0-9]{32}$/.test(expectedIdentity.fileId128)) throw authorityError('request_protocol', 1); + const release = await acquireLease(signal); + let session: WindowsAuthoritySession | undefined; + let capabilityChallenge = randomBytes(16).toString('hex'); + let acquisitionBarrierRan = false; + try { + const activeSession = session = await getBroker(); + const barrierChallenge = beforeOpenForTest ? randomBytes(16).toString('hex') : null; + const hold = requestFrame('hold', { + // A zero-byte protected file is a setup capability. Every nonempty held + // file is an artifact capability, whether its hash is being learned or + // checked against an already authenticated digest. + purpose: expectedBytes === 0 ? 'setup' : 'artifact', + path, + expectedBytes, + expectedVolumeSerial: expectedIdentity.volumeSerial, + expectedFileId128: expectedIdentity.fileId128, + expectedSha256: expectedSha256 ?? null, + challenge: capabilityChallenge, + barrier: barrierChallenge, + }); + let responsePromise = activeSession.receive(BROKER_TIMEOUT_MS, signal); + await activeSession.write(hold); + let ready = await responsePromise; + if (barrierChallenge) { + if (!exactKeys(ready, ['version', 'type', 'id', 'challenge']) + || ready.version !== WINDOWS_AUTHORITY_PROTOCOL_VERSION || ready.type !== 'before-open' + || ready.id !== hold.id || ready.challenge !== barrierChallenge) throw authorityError('ready_protocol', 12); + try { + await beforeOpenForTest!(); + acquisitionBarrierRan = true; + } catch (error) { + activeSession.invalidate(abortError()); + throw error; + } + const continuation = requestFrame('continue', { + id: hold.id, + purpose: hold.purpose, + challenge: capabilityChallenge, + barrier: barrierChallenge, + }); + responsePromise = activeSession.receive(BROKER_TIMEOUT_MS, signal); + await activeSession.write(continuation); + ready = await responsePromise; + } + requestCount++; + const failure = parseFailure(ready, hold.id); + if (failure) throw failure; + const initial = parseInspection(ready, false, true) as WindowsHeldVerification | undefined; + if (!initial || ready.type !== 'held' || ready.id !== hold.id || ready.challenge !== capabilityChallenge + || !exactKeys(ready, RESPONSE_INSPECTION_KEYS)) throw authorityError('ready_protocol', 12); + + let closed = false; + let commandQueue = Promise.resolve(); + const sameInitial = (candidate: WindowsHeldVerification): boolean => + candidate.identity.volumeSerial === initial.identity.volumeSerial + && candidate.identity.fileId128 === initial.identity.fileId128 + && candidate.links === initial.links && candidate.size === initial.size + && candidate.reparseTag === initial.reparseTag && candidate.ownerSid === initial.ownerSid + && candidate.aceCount === initial.aceCount + && candidate.inheritedWriteAces === initial.inheritedWriteAces + && candidate.broadWriteAces === initial.broadWriteAces + && candidate.sha256 === initial.sha256 && candidate.sha1 === initial.sha1; + const exchangeHeld = async (operation: 'read' | 'verify' | 'close', values: Partial, requestSignal?: AbortSignal) => { + let value!: Record; + const run = commandQueue.then(async () => { + throwIfAborted(requestSignal); + value = await activeSession.exchange(requestFrame(operation, { + id: hold.id, + purpose: hold.purpose, + challenge: capabilityChallenge, + ...values, + }), requestSignal); + }); + commandQueue = run.catch(() => undefined); + await run; + return value; + }; + const heldTimeout = setTimeout(() => { + activeSession.invalidate(authorityError('timeout', 18)); + release(); + }, BROKER_SESSION_TIMEOUT_MS); + const capability: WindowsLockedArtifact = { + inspection: initial, + read: async (offset, length, requestSignal) => { + if (closed || !Number.isSafeInteger(offset) || offset < 0 + || !Number.isSafeInteger(length) || length <= 0 || length > MAX_READ_BYTES + || offset + length > Number(initial.size)) throw authorityError('request_protocol', 1); + const result = await exchangeHeld('read', { offset, length }, requestSignal); + if (result.type !== 'bytes' || result.id !== hold.id || result.challenge !== capabilityChallenge + || typeof result.bytes !== 'string' + || !exactKeys(result, ['version', 'type', 'id', 'challenge', 'bytes'])) { + activeSession.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('held_read', 13); + } + const bytes = Buffer.from(result.bytes, 'base64'); + if (bytes.length !== length || bytes.toString('base64') !== result.bytes) { + activeSession.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('held_read', 13); + } + return bytes; + }, + verify: async requestSignal => { + if (closed) throw authorityError('final_verify', 14); + const challenge = randomBytes(16).toString('hex'); + const result = await exchangeHeld('verify', { barrier: challenge }, requestSignal); + const verified = parseInspection(result, false, true) as WindowsHeldVerification | undefined; + if (!verified || result.type !== 'verified' || result.id !== hold.id || result.challenge !== challenge + || !exactKeys(result, RESPONSE_INSPECTION_KEYS) || !sameInitial(verified)) { + activeSession.invalidate(authorityError('stdio_protocol', 16)); + throw authorityError('final_verify', 14); + } + return verified; + }, + close: async requestSignal => { + if (closed) return; + closed = true; + clearTimeout(heldTimeout); + try { + const result = await exchangeHeld('close', {}, requestSignal); + const final = parseInspection(result, false, true) as WindowsHeldVerification | undefined; + if (!final || result.type !== 'closed' || result.id !== hold.id || result.challenge !== '' + || !exactKeys(result, RESPONSE_INSPECTION_KEYS) || !sameInitial(final)) { + throw authorityError('final_verify', 14); + } + lastClosedHeldId = hold.id; + } catch (error) { + activeSession.invalidate(error instanceof Error ? error : authorityError('clean_shutdown', 15)); + throw error; + } finally { + lockedArtifactProcesses.delete(capability); + release(); + } + }, + }; + lockedArtifactProcesses.set(capability, { + session: activeSession, + exited: activeSession.exited, + challenge: capabilityChallenge, + heldId: hold.id, + purpose: hold.purpose, + release, + timeout: heldTimeout, + }); + activeSession.exited.then(() => { + clearTimeout(heldTimeout); + release(); + }).catch(() => { + clearTimeout(heldTimeout); + release(); + }); + return capability; + } catch (error) { + release(); + if (signal?.aborted && session) session.invalidate(abortError()); + if (retry && !acquisitionBarrierRan && retryableInfrastructureError(error)) { + if (brokerSession) brokerSession.invalidate(error as Error); + brokerSession = undefined; + return openWindowsLockedArtifactAttempt( + path, + expectedBytes, + expectedIdentity, + expectedSha256, + beforeOpenForTest, + signal, + false, + ); + } + throw error; + } +}; + +export const openWindowsLockedArtifact = ( + path: string, + expectedBytes: number, + beforeOpenForTest?: () => Promise, + signal?: AbortSignal, + expectedIdentity?: WindowsFileIdentity, + expectedSha256?: string, +): Promise => (async () => { + if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || expectedBytes > BROKER_ARTIFACT_BYTES) { + throw authorityError('request_protocol', 1); + } + if (expectedSha256 !== undefined && !/^[a-f0-9]{64}$/.test(expectedSha256)) throw authorityError('request_protocol', 1); + const setup = expectedIdentity ?? (await inspectWindowsPrivatePath(path)).identity; + return openWindowsLockedArtifactAttempt(path, expectedBytes, setup, expectedSha256, beforeOpenForTest, signal); +})(); + +/** Native-test-only live protocol injection against the persistent child. */ +export const injectWindowsAuthorityProtocolFaultForTest = async ( + kind: 'partial-frame' | 'extra-frame' | 'wrong-purpose' | 'wrong-identity', + path: string, + expectedBytes: number, +): Promise => { + const setup = await inspectWindowsPrivatePath(path); + const release = await acquireLease(); + try { + const session = await getBroker(); + const inspect = requestFrame('inspect', { purpose: 'setup', path, directory: false }); + if (kind === 'partial-frame') { + const response = session.receive(BROKER_TIMEOUT_MS); + const frame = encodeProtocolFrame(JSON.stringify(inspect)); + const split = Math.floor(frame.length / 2); + await session.writeRawForTest([frame.subarray(0, split), frame.subarray(split)]); + const value = await response; + const parsed = parseInspection(value, false, false); + if (!parsed || value.id !== inspect.id || value.type !== 'inspection') throw authorityError('stdio_protocol', 16); + return 'accepted'; + } + if (kind === 'extra-frame') { + const response = session.receive(BROKER_TIMEOUT_MS); + const extra = requestFrame('inspect', { + purpose: 'setup', + path, + directory: false, + }); + await session.writeRawForTest([Buffer.concat([ + encodeProtocolFrame(JSON.stringify(inspect)), + encodeProtocolFrame(JSON.stringify(extra)), + ])]); + await response; + await session.exited; + return 'stdio_protocol'; + } + const request = kind === 'wrong-purpose' + ? requestFrame('inspect', { purpose: 'artifact', path, directory: false }) + : requestFrame('hold', { + purpose: 'setup', + path, + expectedBytes, + expectedVolumeSerial: setup.identity.volumeSerial === '0000000000000000' + ? 'ffffffffffffffff' + : '0000000000000000', + expectedFileId128: setup.identity.fileId128, + expectedSha256: null, + challenge: randomBytes(16).toString('hex'), + }); + try { + await session.exchange(request); + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (error instanceof WindowsAuthorityError) return error.reason; + throw error; + } + } finally { release(); } +}; + +/** Native-test-only held-session ID/purpose confusion injection. */ +export const injectWindowsAuthorityHeldFaultForTest = async ( + held: WindowsLockedArtifact, + kind: 'wrong-id' | 'wrong-purpose' | 'stale-id', +): Promise => { + const process = lockedArtifactProcesses.get(held); + if (!process) throw authorityError('request_protocol', 1); + const frame = requestFrame('read', { + id: kind === 'wrong-id' ? randomBytes(16).toString('hex') + : kind === 'stale-id' ? (lastClosedHeldId ?? randomBytes(16).toString('hex')) : process.heldId, + purpose: kind === 'wrong-purpose' ? (process.purpose === 'setup' ? 'artifact' : 'setup') : process.purpose, + challenge: process.challenge, + offset: 0, + length: 1, + }); + try { + await process.session.exchange(frame); + throw authorityError('stdio_protocol', 16); + } catch (error) { + if (!(error instanceof WindowsAuthorityError)) throw error; + process.session.invalidate(error); + await process.exited; + clearTimeout(process.timeout); + process.release(); + lockedArtifactProcesses.delete(held); + return error.reason; + } +}; + +/** Native-test-only crash injection used to prove that OS termination releases the exact target handle. */ +export const crashWindowsLockedArtifactForTest = async (held: WindowsLockedArtifact): Promise => { + const process = lockedArtifactProcesses.get(held); + if (!process) throw authorityError('request_protocol', 1); + clearTimeout(process.timeout); + process.session.invalidate(authorityError('process_exit', 19)); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + process.exited, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(authorityError('process_exit', 19)), BROKER_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + process.release(); + lockedArtifactProcesses.delete(held); +}; + +export const windowsAuthorityBrokerStatsForTest = (): Readonly<{ + compileCount: number; + requestCount: number; + restartCount: number; + activeProcessCount: number; + queuedEntries: number; +}> => Object.freeze({ + compileCount, + requestCount, + restartCount, + activeProcessCount, + queuedEntries: brokerQueue.length, +}); + +/** Test-only framing probe; it shares the production incremental binary decoder. */ +export const decodeWindowsAuthorityFramesForTest = ( + chunks: readonly Buffer[], + expectedFrames = 1, +): readonly Readonly>[] => { + let buffered: Buffer = Buffer.alloc(0); + const frames: Record[] = []; + for (const chunk of chunks) { + const decoded = decodeProtocolChunk(buffered, chunk); + buffered = decoded.buffered; + for (const frame of decoded.frames) { + let value: unknown; + try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)); } + catch { throw authorityError('stdio_protocol', 16); } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw authorityError('stdio_protocol', 16); + } + frames.push(value as Record); + } + } + if (buffered.length !== 0 || frames.length !== expectedFrames) throw authorityError('stdio_protocol', 16); + return frames; +}; + +export const encodeWindowsAuthorityFrameForTest = (value: string): Buffer => encodeProtocolFrame(value); + +export const parseWindowsAuthorityStartupFailureForTest = (frame: unknown): Error => + parseFailure(frame) ?? authorityError('stdio_protocol', 16); + +export const shutdownWindowsAuthorityBrokerForTest = async (): Promise => { + const session = brokerSession ?? await brokerStartup?.catch(() => undefined); + brokerSession = undefined; + if (session) await session.shutdown(); +}; + +process.once('exit', () => { + for (const child of brokerChildren) if (!child.killed) child.kill(); +}); + +export const smokeWindowsUpdateAuthority = async (path: string): Promise => { + const setup = await inspectWindowsPrivatePath(path); + const exactBytes = Number(setup.size); + if (!Number.isSafeInteger(exactBytes) || exactBytes <= 0) throw authorityError('type_link_size', 5); + const held = await openWindowsLockedArtifact(path, exactBytes, undefined, undefined, setup.identity); + try { + if (!/^[a-f0-9]{16}$/.test(held.inspection.identity.volumeSerial) + || !/^[a-f0-9]{32}$/.test(held.inspection.identity.fileId128) + || !/^[a-f0-9]{64}$/.test(held.inspection.sha256) + || !/^[a-f0-9]{40}$/.test(held.inspection.sha1) + || held.inspection.daclProtected !== true + || held.inspection.reparseTag !== '00000000') throw authorityError('ready_protocol', 12); + await held.read(0, Math.min(1, Number(held.inspection.size))); + const verified = await held.verify(); + if (verified.identity.fileId128 !== held.inspection.identity.fileId128 + || verified.sha256 !== held.inspection.sha256 || verified.sha1 !== held.inspection.sha1) { + throw authorityError('final_verify', 14); + } + } finally { + await held.close(); + } + return Object.freeze([ + 'compile-load', + 'owner-sid', + 'dacl-protection', + 'file-id-info', + 'same-handle-sha256-sha1', + 'reparse-query', + 'no-share-lock', + 'ready-protocol', + 'held-read', + 'clean-shutdown', + ]); +}; diff --git a/apps/desktop/vite.main.config.ts b/apps/desktop/vite.main.config.ts index 3fac6a497..8a685797c 100644 --- a/apps/desktop/vite.main.config.ts +++ b/apps/desktop/vite.main.config.ts @@ -1,6 +1,15 @@ import { defineConfig } from 'vite'; +import { resolveTrustedUpdateBuildConfig } from './src/release-config'; + +const updateConfig = resolveTrustedUpdateBuildConfig(); export default defineConfig({ + define: { + __PROPR_DESKTOP_UPDATE_MANIFEST_URL__: JSON.stringify(updateConfig.manifestUrl), + __PROPR_DESKTOP_UPDATE_PUBLIC_KEY__: JSON.stringify(updateConfig.publicKey), + __PROPR_DESKTOP_UPDATE_SIGNING_IDENTITY__: JSON.stringify(updateConfig.signingIdentity), + __PROPR_DESKTOP_WINDOWS_SIGNER_PINS__: JSON.stringify(updateConfig.windowsSignerPins), + }, build: { sourcemap: true, minify: false, diff --git a/apps/desktop/vite.renderer.config.ts b/apps/desktop/vite.renderer.config.ts index 457d63950..3c5cca231 100644 --- a/apps/desktop/vite.renderer.config.ts +++ b/apps/desktop/vite.renderer.config.ts @@ -3,11 +3,13 @@ import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; import { defineConfig, type Plugin } from 'vite'; import { applyDevelopmentRendererCsp } from './src/security'; +import { resolveDesktopVersion } from './src/release-config'; import { viteFileSystemUrl } from './src/vite-file-system-url'; const rootPackage = JSON.parse( readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'), ) as { version: string }; +const desktopVersion = resolveDesktopVersion(rootPackage.version); const proprUiRoot = fileURLToPath(new URL('../../propr-ui', import.meta.url)); const rendererEntrySource = '../../propr-ui/src/desktop.tsx'; const rendererEntryDevelopmentUrl = viteFileSystemUrl( @@ -60,7 +62,7 @@ export default defineConfig({ postcss: proprUiRoot, }, define: { - __APP_VERSION__: JSON.stringify(rootPackage.version), + __APP_VERSION__: JSON.stringify(desktopVersion), __PROPR_DESKTOP__: 'true', }, plugins: [developmentCspPlugin, react(), compiledRendererCssPlugin], diff --git a/package-lock.json b/package-lock.json index 88956cb9d..fbaf6b13b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -179,20 +179,6 @@ "node": ">= 22.12.0" } }, - "apps/desktop/node_modules/@electron-forge/maker-base": { - "version": "8.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-8.0.0-alpha.10.tgz", - "integrity": "sha512-aZ7YlU785r/1VPy0h1HHy1VEiufqMX0fd4tzHcAWwDfZguajfhnGioPfgCaEVKBWyAgV3v7Pge2FkL7YcRsxsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron-forge/shared-types": "8.0.0-alpha.10", - "which": "^6.0.0" - }, - "engines": { - "node": ">= 22.12.0" - } - }, "apps/desktop/node_modules/@electron-forge/maker-deb": { "version": "8.0.0-alpha.10", "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-8.0.0-alpha.10.tgz", @@ -304,52 +290,6 @@ "node": ">= 22.12.0" } }, - "apps/desktop/node_modules/@electron-forge/shared-types": { - "version": "8.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-8.0.0-alpha.10.tgz", - "integrity": "sha512-JdwOXHXXjh1L1rgLcQJfyCX8cHgvognmuol/udDUIx9/JzMc+AZhNnsFN8JriRYunYaFrVLTHe0H8f8GQXO/LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron-forge/tracer": "8.0.0-alpha.10", - "@electron/packager": "^20.0.1", - "@electron/rebuild": "^4.0.1", - "listr2": "^7.0.2" - }, - "engines": { - "node": ">= 22.12.0" - } - }, - "apps/desktop/node_modules/@electron-forge/tracer": { - "version": "8.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-8.0.0-alpha.10.tgz", - "integrity": "sha512-aoW9P+KoTtO0KQaISdJXi3sVB5k12P1kA6pK0NsgJTEbsB2i5O6c8zfor/U5eJUeb9GAVscAIHKShYuUycgZqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chrome-trace-event": "^1.0.3" - }, - "engines": { - "node": ">= 22.12.0" - } - }, - "apps/desktop/node_modules/@electron/asar": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.0.tgz", - "integrity": "sha512-k/FFC/NQoTykGBi/Ga4L4KorsAKDdkK0LfNVG90eUG6vPVpJaL3iR9V1ZLbzBAF3QpojK5DcaGOdQOheKZv4JQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^13.0.2", - "minimatch": "^10.0.1" - }, - "bin": { - "asar": "bin/asar.mjs" - }, - "engines": { - "node": ">=22.12.0" - } - }, "apps/desktop/node_modules/@electron/fuses": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-2.1.3.tgz", @@ -363,386 +303,80 @@ "node": ">=22.12.0" } }, - "apps/desktop/node_modules/@electron/get": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", - "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", - "progress": "^2.0.3", - "semver": "^7.6.3", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "undici": "^7.24.4" - } - }, - "apps/desktop/node_modules/@electron/notarize": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", - "integrity": "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==", + "apps/desktop/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": ">= 22.12.0" - } - }, - "apps/desktop/node_modules/@electron/osx-sign": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-2.7.0.tgz", - "integrity": "sha512-9DGhNqKMl6ibkhUoXbN7OHX2gZznfY10L3ZwG0u6r667Kfb6kec4JEfFTXftoqzmOfZ+OzwDbr4p/nKBMHnz0g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.3.4", - "isbinaryfile": "^4.0.8", - "plist": "^3.0.5", - "semver": "^7.7.1" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.mjs", - "electron-osx-sign": "bin/electron-osx-sign.mjs" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "apps/desktop/node_modules/@electron/packager": { - "version": "20.3.0", - "resolved": "https://registry.npmjs.org/@electron/packager/-/packager-20.3.0.tgz", - "integrity": "sha512-3MvgJgy6YJ5ti0oGGBKrWKdYwpTaoRrhranMDgHSQ6i5t56yZV9IRDyoXTuqBp97LiKqnZeAMe2wTcF/9+fP5g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@electron-internal/extract-zip": "^1.0.1", - "@electron/asar": "^4.0.1", - "@electron/get": "^5.0.0", - "@electron/notarize": "^3.1.0", - "@electron/osx-sign": "^2.2.0", - "@electron/universal": "^3.0.1", - "@electron/windows-sign": "^2.0.2", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.4.1", - "filenamify": "^6.0.0", - "galactus": "^2.0.2", - "graceful-fs": "^4.2.11", - "junk": "^4.0.1", - "plist": "^3.1.0", - "resedit": "^2.0.3", - "semver": "^7.7.2", - "yargs-parser": "^22.0.0" - }, - "bin": { - "electron-packager": "bin/electron-packager.mjs" - }, "engines": { - "node": ">= 22.12.0" - }, - "funding": { - "url": "https://github.com/electron/packager?sponsor=1" + "node": ">=16" } }, - "apps/desktop/node_modules/@electron/rebuild": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", - "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.1.1", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^12.2.0", - "read-binary-file-arch": "^1.0.6" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } + "license": "MIT" }, - "apps/desktop/node_modules/@electron/universal": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-3.0.6.tgz", - "integrity": "sha512-MonS1kfkZdSEkLZI0pdR/TCx8ecxwRSFm7sORfwIkDI9UaIbHnk4Mgeqq+Ob9qDQRV8LZ9+hHCmimpA9BRcNxw==", - "dev": true, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", "license": "MIT", "dependencies": { - "@electron/asar": "^4.0.0", - "debug": "^4.3.1", - "plist": "^3.1.0" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "apps/desktop/node_modules/@electron/windows-sign": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-2.0.6.tgz", - "integrity": "sha512-ESWgNkFsXFH06I5EB2uHv3hmZA3yF4j9GTB77W74x3b5KZNItxggNzuADw41HUy6mhnC2K+E2mT0Xgc4YKu3IQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.3.4", - "graceful-fs": "^4.2.11", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.mjs" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=22.12.0" - } - }, - "apps/desktop/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" + "node": ">=18" } }, - "apps/desktop/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "dev": true, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "apps/desktop/node_modules/filename-reserved-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", - "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "apps/desktop/node_modules/filenamify": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", - "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "filename-reserved-regex": "^3.0.0" + "node_modules/@anthropic-ai/claude-code": { + "version": "2.1.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.220.tgz", + "integrity": "sha512-ogBrvwkqF9f8okmnXKxmRNHuvtFxFEffe5pWdqOV3iQDxlUOKirFqnyWC7NGXXnDA4WkkbPH8pvSbwyCR2Auyw==", + "hasInstallScript": true, + "license": "SEE LICENSE IN README.md", + "bin": { + "claude": "bin/claude.exe" }, "engines": { - "node": ">=16" + "node": ">=22.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/desktop/node_modules/flora-colossus": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-3.0.2.tgz", - "integrity": "sha512-Jk78K/Tzt6saxQPGChlJw69xuFGpWyTSAS8EdU0h/FyXwD2K46yNOXmo6nRHcZ9ooekyBAzMkwmiGNt7wOC5zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.1" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "apps/desktop/node_modules/galactus": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/galactus/-/galactus-2.0.2.tgz", - "integrity": "sha512-HmKyTFGomdAchz4umx8MwBnrnfFmdpwiTyGA4ZOF7rya2Lmgbc9qate4yweInL+0gUBVImhaz12SBGpW3SY4Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.1", - "flora-colossus": "^3.0.2" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "apps/desktop/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "apps/desktop/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "apps/desktop/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "apps/desktop/node_modules/junk": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz", - "integrity": "sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/desktop/node_modules/node-abi": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", - "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.6.3" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "apps/desktop/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "apps/desktop/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", - "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@anthropic-ai/claude-code": { - "version": "2.1.220", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.220.tgz", - "integrity": "sha512-ogBrvwkqF9f8okmnXKxmRNHuvtFxFEffe5pWdqOV3iQDxlUOKirFqnyWC7NGXXnDA4WkkbPH8pvSbwyCR2Auyw==", - "hasInstallScript": true, - "license": "SEE LICENSE IN README.md", - "bin": { - "claude": "bin/claude.exe" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "@anthropic-ai/claude-code-darwin-arm64": "2.1.220", - "@anthropic-ai/claude-code-darwin-x64": "2.1.220", - "@anthropic-ai/claude-code-linux-arm64": "2.1.220", - "@anthropic-ai/claude-code-linux-arm64-musl": "2.1.220", - "@anthropic-ai/claude-code-linux-x64": "2.1.220", - "@anthropic-ai/claude-code-linux-x64-musl": "2.1.220", - "@anthropic-ai/claude-code-win32-arm64": "2.1.220", - "@anthropic-ai/claude-code-win32-x64": "2.1.220" + "optionalDependencies": { + "@anthropic-ai/claude-code-darwin-arm64": "2.1.220", + "@anthropic-ai/claude-code-darwin-x64": "2.1.220", + "@anthropic-ai/claude-code-linux-arm64": "2.1.220", + "@anthropic-ai/claude-code-linux-arm64-musl": "2.1.220", + "@anthropic-ai/claude-code-linux-x64": "2.1.220", + "@anthropic-ai/claude-code-linux-x64-musl": "2.1.220", + "@anthropic-ai/claude-code-win32-arm64": "2.1.220", + "@anthropic-ai/claude-code-win32-x64": "2.1.220" } }, "node_modules/@anthropic-ai/claude-code-darwin-arm64": { @@ -1397,105 +1031,427 @@ "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@electron-forge/maker-base": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-8.0.0-alpha.10.tgz", + "integrity": "sha512-aZ7YlU785r/1VPy0h1HHy1VEiufqMX0fd4tzHcAWwDfZguajfhnGioPfgCaEVKBWyAgV3v7Pge2FkL7YcRsxsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "8.0.0-alpha.10", + "which": "^6.0.0" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/@electron-forge/maker-base/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@electron-forge/maker-base/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@electron-forge/shared-types": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-8.0.0-alpha.10.tgz", + "integrity": "sha512-JdwOXHXXjh1L1rgLcQJfyCX8cHgvognmuol/udDUIx9/JzMc+AZhNnsFN8JriRYunYaFrVLTHe0H8f8GQXO/LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "8.0.0-alpha.10", + "@electron/packager": "^20.0.1", + "@electron/rebuild": "^4.0.1", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/@electron-forge/tracer": { + "version": "8.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-8.0.0-alpha.10.tgz", + "integrity": "sha512-aoW9P+KoTtO0KQaISdJXi3sVB5k12P1kA6pK0NsgJTEbsB2i5O6c8zfor/U5eJUeb9GAVscAIHKShYuUycgZqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/notarize": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", + "integrity": "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-2.7.0.tgz", + "integrity": "sha512-9DGhNqKMl6ibkhUoXbN7OHX2gZznfY10L3ZwG0u6r667Kfb6kec4JEfFTXftoqzmOfZ+OzwDbr4p/nKBMHnz0g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.3.4", + "isbinaryfile": "^4.0.8", + "plist": "^3.0.5", + "semver": "^7.7.1" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.mjs", + "electron-osx-sign": "bin/electron-osx-sign.mjs" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/packager": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@electron/packager/-/packager-20.3.0.tgz", + "integrity": "sha512-3MvgJgy6YJ5ti0oGGBKrWKdYwpTaoRrhranMDgHSQ6i5t56yZV9IRDyoXTuqBp97LiKqnZeAMe2wTcF/9+fP5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/asar": "^4.0.1", + "@electron/get": "^5.0.0", + "@electron/notarize": "^3.1.0", + "@electron/osx-sign": "^2.2.0", + "@electron/universal": "^3.0.1", + "@electron/windows-sign": "^2.0.2", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.4.1", + "filenamify": "^6.0.0", + "galactus": "^2.0.2", + "graceful-fs": "^4.2.11", + "junk": "^4.0.1", + "plist": "^3.1.0", + "resedit": "^2.0.3", + "semver": "^7.7.2", + "yargs-parser": "^22.0.0" + }, + "bin": { + "electron-packager": "bin/electron-packager.mjs" + }, + "engines": { + "node": ">= 22.12.0" + }, + "funding": { + "url": "https://github.com/electron/packager?sponsor=1" } }, - "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", + "node_modules/@electron/packager/node_modules/@electron/asar": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.0.tgz", + "integrity": "sha512-k/FFC/NQoTykGBi/Ga4L4KorsAKDdkK0LfNVG90eUG6vPVpJaL3iR9V1ZLbzBAF3QpojK5DcaGOdQOheKZv4JQ==", + "dev": true, "license": "MIT", "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" + "glob": "^13.0.2", + "minimatch": "^10.0.1" }, - "peerDependencies": { - "@dnd-kit/core": "^6.3.0", - "react": ">=16.8.0" + "bin": { + "asar": "bin/asar.mjs" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "license": "MIT", + "node_modules/@electron/packager/node_modules/@electron/windows-sign": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-2.0.6.tgz", + "integrity": "sha512-ESWgNkFsXFH06I5EB2uHv3hmZA3yF4j9GTB77W74x3b5KZNItxggNzuADw41HUy6mhnC2K+E2mT0Xgc4YKu3IQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "tslib": "^2.0.0" + "debug": "^4.3.4", + "graceful-fs": "^4.2.11", + "postject": "^1.0.0-alpha.6" }, - "peerDependencies": { - "react": ">=16.8.0" + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.mjs" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@electron-internal/extract-zip": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", - "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "node_modules/@electron/packager/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "BSD-2-Clause", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, "engines": { - "node": ">=22.12.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, "bin": { - "asar": "bin/asar.js" + "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=10.12.0" + "node": ">=22.12.0" } }, - "node_modules/@electron/asar/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } }, - "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/@electron/universal": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-3.0.6.tgz", + "integrity": "sha512-MonS1kfkZdSEkLZI0pdR/TCx8ecxwRSFm7sORfwIkDI9UaIbHnk4Mgeqq+Ob9qDQRV8LZ9+hHCmimpA9BRcNxw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@electron/asar": "^4.0.0", + "debug": "^4.3.1", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/@electron/asar/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "node_modules/@electron/universal/node_modules/@electron/asar": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.0.tgz", + "integrity": "sha512-k/FFC/NQoTykGBi/Ga4L4KorsAKDdkK0LfNVG90eUG6vPVpJaL3iR9V1ZLbzBAF3QpojK5DcaGOdQOheKZv4JQ==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "glob": "^13.0.2", + "minimatch": "^10.0.1" + }, + "bin": { + "asar": "bin/asar.mjs" + }, "engines": { - "node": ">= 6" + "node": ">=22.12.0" } }, - "node_modules/@electron/asar/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@electron/universal/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", - "optional": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@electron/windows-sign": { @@ -6290,27 +6246,6 @@ "node": ">= 4.0.0" } }, - "node_modules/electron/node_modules/@electron/get": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", - "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", - "progress": "^2.0.3", - "semver": "^7.6.3", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "undici": "^7.24.4" - } - }, "node_modules/electron/node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -6321,19 +6256,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/electron/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/electron/node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -7717,6 +7639,35 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/filename-reserved-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", + "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", + "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "license": "MIT", @@ -7782,6 +7733,19 @@ "dev": true, "license": "ISC" }, + "node_modules/flora-colossus": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-3.0.2.tgz", + "integrity": "sha512-Jk78K/Tzt6saxQPGChlJw69xuFGpWyTSAS8EdU0h/FyXwD2K46yNOXmo6nRHcZ9ooekyBAzMkwmiGNt7wOC5zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/format": { "version": "0.2.2", "engines": { @@ -7896,6 +7860,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/galactus": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/galactus/-/galactus-2.0.2.tgz", + "integrity": "sha512-HmKyTFGomdAchz4umx8MwBnrnfFmdpwiTyGA4ZOF7rya2Lmgbc9qate4yweInL+0gUBVImhaz12SBGpW3SY4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1", + "flora-colossus": "^3.0.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/gar": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/gar/-/gar-1.0.4.tgz", @@ -9165,6 +9143,19 @@ "npm": ">=6" } }, + "node_modules/junk": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz", + "integrity": "sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jwa": { "version": "1.4.2", "license": "MIT", @@ -15127,6 +15118,16 @@ "node": ">=18" } }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index 668efd4f2..bcc612975 100644 --- a/package.json +++ b/package.json @@ -73,10 +73,12 @@ "desktop": "npm run dev -w @propr/desktop", "desktop:dev": "npm run dev -w @propr/desktop", "desktop:prepare": "npm run build -w @propr/shared && npm run build -w @propr/client", + "desktop:broker:build": "npm run broker:build -w @propr/desktop", "desktop:typecheck": "npm run typecheck -w @propr/desktop && npm run typecheck -w propr-ui", "desktop:test": "npm run test -w @propr/desktop", "desktop:package": "npm run package -w @propr/desktop", "desktop:smoke": "npm run smoke:package -w @propr/desktop", + "desktop:smoke:inspect": "npm run smoke:inspect -w @propr/desktop", "desktop:make": "npm run make -w @propr/desktop", "audit:runtime": "npm audit --package-lock-only --omit=dev --audit-level=low", "desktop:audit:packaging": "npm audit --package-lock-only --workspace=@propr/desktop --include=dev --audit-level=high", diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index 8fb9bbb4b..24ff3e8a8 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -50,6 +50,13 @@ function deferred() { return { promise, resolve }; } +const renderConnectedExperience = (adapters: DesktopAdapters, content?: string) => render( + + + {content &&
{content}
} +
+); + describe('DesktopExperience', () => { beforeEach(() => { vi.clearAllMocks(); @@ -222,11 +229,7 @@ describe('DesktopExperience', () => { it('opens instance management with the desktop shortcut and exposes connection status', async () => { const adapters = adaptersFor([localProfile], localProfile.id); - render( - - - - ); + renderConnectedExperience(adapters); expect(await screen.findByRole('button', { name: 'Connected: This computer' })).toBeInTheDocument(); fireEvent.keyDown(document, { key: ',', ctrlKey: true }); @@ -237,11 +240,7 @@ describe('DesktopExperience', () => { it('traps modal focus, makes the app inert, and restores focus to the opener', async () => { const adapters = adaptersFor([localProfile], localProfile.id); - render( - - - - ); + renderConnectedExperience(adapters); const opener = await screen.findByRole('button', { name: 'Connected: This computer' }); opener.focus(); @@ -270,11 +269,11 @@ describe('DesktopExperience', () => { it('connects a new instance added from the manager', async () => { const adapters = adaptersFor([localProfile], localProfile.id); - render(
Connected app
); + renderConnectedExperience(adapters, 'Connected app'); expect(await screen.findByText('Connected app')).toBeInTheDocument(); vi.clearAllMocks(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); fireEvent.click(await screen.findByRole('button', { name: /Add instance/i })); fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'New server' } }); fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://new.example.com/' } }); @@ -296,12 +295,7 @@ describe('DesktopExperience', () => { .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }) .mockImplementationOnce(() => pendingProbe.promise); const adapters = adaptersFor([localProfile], localProfile.id, probe); - render( - - -
Connected app
-
- ); + renderConnectedExperience(adapters, 'Connected app'); expect(await screen.findByText('Connected app')).toBeInTheDocument(); fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); @@ -327,11 +321,11 @@ describe('DesktopExperience', () => { it('reconnects an edited active instance but saves an inactive edit without connecting', async () => { const adapters = adaptersFor([localProfile, remoteProfile], localProfile.id); - render(
Connected app
); + renderConnectedExperience(adapters, 'Connected app'); expect(await screen.findByText('Connected app')).toBeInTheDocument(); vi.clearAllMocks(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://active.example.com/' } }); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); @@ -343,7 +337,7 @@ describe('DesktopExperience', () => { expect(apiMock.setApiBaseUrl).toHaveBeenLastCalledWith('https://active.example.com'); vi.clearAllMocks(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); fireEvent.click(await screen.findByRole('button', { name: 'Edit Team server' })); fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Renamed team server' } }); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); @@ -359,11 +353,11 @@ describe('DesktopExperience', () => { .mockResolvedValueOnce({ status: 'ready', version: '0.8.15' }) .mockResolvedValueOnce({ status: 'offline', message: 'The updated server is unavailable.' }); const adapters = adaptersFor([localProfile], localProfile.id, probe); - render(
Connected app
); + renderConnectedExperience(adapters, 'Connected app'); expect(await screen.findByText('Connected app')).toBeInTheDocument(); vi.clearAllMocks(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); fireEvent.click(await screen.findByRole('button', { name: 'Edit This computer' })); fireEvent.change(screen.getByLabelText('Instance URL'), { target: { value: 'https://unavailable.example.com/' } }); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); @@ -381,10 +375,10 @@ describe('DesktopExperience', () => { .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('Profile storage is locked.')) .mockResolvedValueOnce(undefined); - render(
Connected app
); + renderConnectedExperience(adapters, 'Connected app'); expect(await screen.findByText('Connected app')).toBeInTheDocument(); - fireEvent.keyDown(document, { key: ',', ctrlKey: true }); + fireEvent.click(await screen.findByRole('button', { name: 'Connected: This computer' })); fireEvent.click(await screen.findByRole('button', { name: 'Edit Team server' })); fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Retryable edit' } }); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); diff --git a/propr-ui/src/desktop/DesktopPresentationBoundary.tsx b/propr-ui/src/desktop/DesktopPresentationBoundary.tsx index 339843ebe..9e85ddab2 100644 --- a/propr-ui/src/desktop/DesktopPresentationBoundary.tsx +++ b/propr-ui/src/desktop/DesktopPresentationBoundary.tsx @@ -12,4 +12,3 @@ export const DesktopPresentationBoundary: React.FC{desktop} : fallback; }; -