diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 87e9ad99fb..ab02b83292 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -12,14 +12,18 @@ on: installer_base_name: required: true type: string + use_self_signed_cert: + description: "Use self-signed code signing certificate" + required: false + type: boolean + default: false # when true, uses self-signed certificate jobs: build-windows: env: - AC_USERNAME: ${{ secrets.AC_USERNAME }} - AC_PASSWORD: ${{ secrets.AC_PASSWORD }} BUILD_TYPE: ${{ inputs.build_type }} VERSION: ${{ inputs.version }} + SIGNPATH_SIGNING_POLICY: ${{ inputs.use_self_signed_cert && vars.SIGNPATH_SIGNING_POLICY_SLUG_TEST || vars.SIGNPATH_SIGNING_POLICY_SLUG }} permissions: contents: "read" id-token: "write" @@ -99,13 +103,71 @@ jobs: Write-Host "APP_NAME=$name" Write-Host "APP_VERSION=$version" - - name: Build Windows release + - name: Build Windows binaries shell: pwsh - env: - FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} run: | dart pub global activate flutter_distributor make windows-release + if ($LASTEXITCODE -ne 0) { + Write-Error "make windows-release failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + + Write-Host "=== Files in build/windows/x64/runner/Release/ ===" + if (Test-Path "build/windows/x64/runner/Release/") { + Get-ChildItem -Path "build/windows/x64/runner/Release/" -Recurse | Select-Object -First 20 FullName + } else { + Write-Warning "Release directory does not exist" + } + + - name: Sign embedded binaries + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + run: | + $buildDir = "build/windows/x64/runner/Release" + $signScript = "./scripts/ci/sign-windows.ps1" + + # Third-party binaries that are already signed by their vendors + $thirdParty = @( + 'wintun.dll', # WinTun + 'flutter_windows.dll', # Google/Flutter + 'WebView2Loader.dll', # Microsoft + 'WinSparkle.dll' # WinSparkle + ) + + # Discover all EXEs and DLLs, excluding third-party signed binaries + $binaries = Get-ChildItem -Path $buildDir -Include '*.exe','*.dll' -Recurse -File | + Where-Object { $thirdParty -notcontains $_.Name } | + Select-Object -ExpandProperty FullName + # Sign each binary + foreach ($binary in $binaries) { + if (Test-Path $binary) { + Write-Host "Signing $binary..." + & $signScript ` + -FilePath $binary ` + -SigningPolicy "${{ env.SIGNPATH_SIGNING_POLICY }}" ` + -OrganizationId "${{ vars.SIGNPATH_ORG_ID }}" ` + -ProjectSlug "${{ vars.SIGNPATH_PROJECT_SLUG }}" ` + -ApiToken $env:SIGNPATH_API_TOKEN ` + -Description "GitHub Actions build ${{ inputs.version }}" + + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to sign $binary" + exit 1 + } + } else { + Write-Warning "Binary not found, skipping: $binary" + } + } + + Write-Host "All binaries signed successfully" + + - name: Package installer + shell: pwsh + env: + FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} + run: | flutter_distributor package ` --platform windows ` --targets "exe" ` @@ -113,28 +175,31 @@ jobs: --build-dart-define=BUILD_TYPE=${{ env.BUILD_TYPE }} ` --build-dart-define=VERSION=${{ inputs.version }} ` --flutter-build-args=verbose + + Write-Host "" + Write-Host "=== Contents of dist/$env:APP_VERSION/ ===" + Get-ChildItem -Path "dist/$env:APP_VERSION/" -Recurse | Select-Object FullName + Write-Host "" + Move-Item "dist/$env:APP_VERSION/$env:APP_NAME-$env:APP_VERSION-windows-setup.exe" "$env:FULL_INSTALLER_NAME.exe" - - name: Sign EXE with Azure Code Signing - uses: getlantern/trusted-signing-action@main - with: - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} - endpoint: https://wus2.codesigning.azure.net/ - code-signing-account-name: code-signing - certificate-profile-name: Lantern - files-folder: ${{ github.workspace }}/ - files-folder-filter: exe,dll,msix - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 + - name: Sign installer + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} + run: | + ./scripts/ci/sign-windows.ps1 ` + -FilePath "$env:FULL_INSTALLER_NAME.exe" ` + -SigningPolicy "${{ env.SIGNPATH_SIGNING_POLICY }}" ` + -OrganizationId "${{ vars.SIGNPATH_ORG_ID }}" ` + -ProjectSlug "${{ vars.SIGNPATH_PROJECT_SLUG }}" ` + -ApiToken $env:SIGNPATH_API_TOKEN ` + -Description "Installer - GitHub Actions build ${{ inputs.version }}" - name: Upload Windows installer uses: actions/upload-artifact@v4 - env: - FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} with: name: lantern-installer-exe - path: ${{ env.FULL_INSTALLER_NAME }}.exe + path: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.exe retention-days: 2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c686535fa7..412f75f71c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -188,6 +188,16 @@ jobs: build_type: ${{ needs.set-metadata.outputs.build_type }} installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} + build-windows: + needs: [set-metadata, release-create] + uses: ./.github/workflows/build-windows.yml + secrets: inherit + if: ${{ needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'windows') }} + with: + version: ${{ needs.set-metadata.outputs.version }} + build_type: ${{ needs.set-metadata.outputs.build_type }} + installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} + build-linux: needs: [set-metadata, release-create] uses: ./.github/workflows/build-linux.yml @@ -265,10 +275,19 @@ jobs: --notes "Build [in progress](${WORKFLOW_URL})..." upload-s3: - needs: [set-metadata, build-macos, build-linux, build-android, build-ios] + needs: + [ + set-metadata, + build-macos, + build-windows, + build-linux, + build-android, + build-ios, + ] if: | !cancelled() && (needs.build-macos.result == 'success' || needs.build-macos.result == 'skipped') && + (needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') && (needs.build-ios.result == 'success' || needs.build-ios.result == 'skipped') @@ -336,6 +355,7 @@ jobs: set-metadata, release-create, build-macos, + build-windows, build-linux, build-android, build-ios, @@ -344,6 +364,7 @@ jobs: !cancelled() && needs.release-create.result == 'success' && (needs.build-macos.result == 'success' || needs.build-macos.result == 'skipped') && + (needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') && (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') && (needs.build-ios.result == 'success' || needs.build-ios.result == 'skipped') @@ -393,6 +414,7 @@ jobs: } upload_if_exists "lantern-installer-dmg/${FULL_NAME}.dmg" + upload_if_exists "lantern-installer-exe/${FULL_NAME}.exe" upload_if_exists "lantern-installer-apk/${FULL_NAME}.apk" upload_if_exists "lantern-installer-deb/${FULL_NAME}.deb" upload_if_exists "lantern-installer-rpm/${FULL_NAME}.rpm" diff --git a/Makefile b/Makefile index dabc4e9e03..66361d4060 100644 --- a/Makefile +++ b/Makefile @@ -409,7 +409,7 @@ windows-debug: windows .PHONY: build-windows-release build-windows-release: @echo "Building Flutter app (release) for Windows..." - flutter build windows --release + flutter build windows --release --verbose .PHONY: windows-release windows-release: clean windows pubget gen build-windows-release prepare-windows-release diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index e5531a1420..95293ad627 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -117,9 +117,11 @@ endforeach(bundled_library) # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) +if(EXISTS "${NATIVE_ASSETS_DIR}") + install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. diff --git a/scripts/ci/format.sh b/scripts/ci/format.sh index d326a8f1a5..be162a9256 100755 --- a/scripts/ci/format.sh +++ b/scripts/ci/format.sh @@ -65,6 +65,10 @@ release-notes) echo "- [macOS (.dmg)](${LATEST_URL}/${FULL_INSTALLER_NAME}.dmg) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.dmg))" fi + if should_include "windows"; then + echo "- [Windows (.exe)](${LATEST_URL}/${FULL_INSTALLER_NAME}.exe) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.exe))" + fi + if should_include "android"; then echo "- [Android (.apk)](${LATEST_URL}/${FULL_INSTALLER_NAME}.apk) ([permalink](${VERSION_URL}/${FULL_INSTALLER_NAME}.apk))" fi @@ -94,6 +98,10 @@ slack) text="${text}\n• macOS <${LATEST_URL}/${FULL_INSTALLER_NAME}.dmg|${FULL_INSTALLER_NAME}.dmg> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.dmg|permalink>)" fi + if should_include "windows"; then + text="${text}\n• Windows <${LATEST_URL}/${FULL_INSTALLER_NAME}.exe|${FULL_INSTALLER_NAME}.exe> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.exe|permalink>)" + fi + if should_include "android"; then text="${text}\n• Android <${LATEST_URL}/${FULL_INSTALLER_NAME}.apk|${FULL_INSTALLER_NAME}.apk> (<${VERSION_URL}/${FULL_INSTALLER_NAME}.apk|permalink>)" fi diff --git a/scripts/ci/publish-to-s3.sh b/scripts/ci/publish-to-s3.sh index 7061b4c73c..e4df4ad615 100755 --- a/scripts/ci/publish-to-s3.sh +++ b/scripts/ci/publish-to-s3.sh @@ -84,7 +84,7 @@ upload_artifact() { # platform:extension declare -a artifacts=( "macos:dmg" - # "windows:exe" # TODO: re-enable when windows is built + "windows:exe" "android:apk" "linux:deb" "linux:rpm" diff --git a/scripts/ci/sign-windows.ps1 b/scripts/ci/sign-windows.ps1 new file mode 100644 index 0000000000..dd37d0b0e4 --- /dev/null +++ b/scripts/ci/sign-windows.ps1 @@ -0,0 +1,176 @@ +<# +.SYNOPSIS + Signs a Windows binary using SignPath. + +.DESCRIPTION + Submits a file to SignPath for code signing, waits for completion, + and downloads the signed artifact back to the original path. + +.PARAMETER FilePath + Path to the file to sign. The signed file will overwrite this path. + +.PARAMETER SigningPolicy + SignPath signing policy slug ('prod-policy' or 'test-policy'). + +.PARAMETER OrganizationId + SignPath organization ID. + +.PARAMETER ProjectSlug + SignPath project slug. + +.PARAMETER ApiToken + SignPath API token. + +.PARAMETER Description + Optional description for the signing request. + +.PARAMETER MaxAttempts + Maximum number of polling attempts (default: 60). + +.PARAMETER PollIntervalSeconds + Seconds between polling attempts (default: 10). + +.PARAMETER IsTestCertificate + When set, allows relaxed signature verification for test/self-signed certificates. + +.EXAMPLE + ./sign-windows.ps1 -FilePath "build/lantern.exe" -SigningPolicy "prod-policy" ` + -OrganizationId $env:SIGNPATH_ORG_ID -ProjectSlug "lantern" -ApiToken $env:SIGNPATH_API_TOKEN +#> + +param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [ValidateSet("prod-policy", "test-policy")] + [string]$SigningPolicy, + + [Parameter(Mandatory = $true)] + [string]$OrganizationId, + + [Parameter(Mandatory = $true)] + [string]$ProjectSlug, + + [Parameter(Mandatory = $true)] + [string]$ApiToken, + + [Parameter(Mandatory = $false)] + [string]$Description = "", + + [Parameter(Mandatory = $false)] + [int]$MaxAttempts = 60, + + [Parameter(Mandatory = $false)] + [int]$PollIntervalSeconds = 10, + + [Parameter(Mandatory = $false)] + [switch]$IsTestCertificate = $false +) + +$ErrorActionPreference = "Stop" + +# Validate file exists +if (-not (Test-Path $FilePath)) { + Write-Error "File not found: $FilePath" +} + +$fileName = Split-Path $FilePath -Leaf +Write-Host "=== SignPath Signing ===" +Write-Host "File: $fileName" +Write-Host "Policy: $SigningPolicy" +Write-Host "========================" + +# Submit signing request +Write-Host "Submitting signing request..." + +$response = Invoke-WebRequest -Method POST ` + -Uri "https://app.signpath.io/API/v1/$OrganizationId/SigningRequests" ` + -Headers @{ "Authorization" = "Bearer $ApiToken" } ` + -SkipHttpErrorCheck ` + -Form @{ + "ProjectSlug" = $ProjectSlug + "SigningPolicySlug" = $SigningPolicy + "Artifact" = Get-Item $FilePath + "Description" = if ($Description) { $Description } else { "Signing $fileName" } + } + +if ($response.StatusCode -ne 201) { + Write-Error "Failed to submit signing request: HTTP $($response.StatusCode) - $($response.Content)" +} + +$signRequestUrl = $response.Headers.Location[0] +Write-Host "Signing request submitted: $signRequestUrl" + +# Poll for completion +Write-Host "Waiting for signing to complete..." + +$attempt = 0 +:certStatusCheck while ($attempt -lt $MaxAttempts) { + Start-Sleep -Seconds $PollIntervalSeconds + $attempt++ + + $status = Invoke-RestMethod -Method GET ` + -Uri $signRequestUrl ` + -Headers @{ "Authorization" = "Bearer $ApiToken" } ` + -SkipHttpErrorCheck + + Write-Host "Status: $($status.Status) (attempt $attempt/$MaxAttempts)" + + switch ($status.Status) { + { $_ -in @("Failed", "Denied") } { + Write-Error "Signing failed with status: $($status.Status)" + } + "Completed" { + break certStatusCheck + } + { $attempt -ge $MaxAttempts } { + Write-Error "Timeout waiting for signing to complete after $MaxAttempts attempts" + } + } +} + +# Download signed artifact +Write-Host "Signing completed successfully!" + +$tempFile = "$FilePath.signed" +Invoke-WebRequest -Method GET ` + -Uri "$signRequestUrl/SignedArtifact" ` + -Headers @{ "Authorization" = "Bearer $ApiToken" } ` + -OutFile $tempFile + +Move-Item -Force $tempFile $FilePath +Write-Host "Downloaded signed artifact to: $FilePath" + +# Verify signature +$sig = Get-AuthenticodeSignature -FilePath $FilePath +Write-Host "=== Signature Verification ===" +Write-Host "Status: $($sig.Status)" +Write-Host "Signer: $($sig.SignerCertificate.Subject)" +Write-Host "Thumbprint: $($sig.SignerCertificate.Thumbprint)" +Write-Host "==============================" + +if ($sig.Status -ne "Valid") { + $isSelfSignedFlow = $IsTestCertificate + + # Always fail on HashMismatch regardless of policy. + if ($sig.Status -eq "HashMismatch") { + Write-Error "Signature verification failed (hash mismatch): $($sig.Status) for policy '$SigningPolicy'" + } + + if (-not $isSelfSignedFlow) { + # For production/EV signing, require a strictly valid signature. + Write-Error "Signature verification failed for policy '$SigningPolicy': $($sig.Status)" + } + + # For self-signed/test policies, allow only a narrow set of expected statuses. + $allowedSelfSignedStatuses = @("Valid", "UnknownError") + if ($allowedSelfSignedStatuses -notcontains $sig.Status) { + Write-Error "Signature verification failed for self-signed/test policy '$SigningPolicy': $($sig.Status)" + } + + Write-Warning "Signature status is $($sig.Status) for self-signed/test policy '$SigningPolicy' - this may be expected for self-signed certificates" +} + +Write-Host "Signing complete: $fileName" +$global:LASTEXITCODE = 0 diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 00f6d34fbc..89c6a099e9 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -52,15 +52,6 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") -# -## Copy liblantern.dll for local development -set(LANTERN_DIR "..") -set(LIBLANTERN_DLL "${LANTERN_DIR}/bin/windows/liblantern.dll") - -install(FILES "${LIBLANTERN_DLL}" - DESTINATION ${CMAKE_INSTALL_PREFIX} - COMPONENT Runtime) - # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -89,6 +80,12 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR} install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) +# Copy liblantern.dll +set(LANTERN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/..") +set(LIBLANTERN_DLL "${LANTERN_DIR}/bin/windows/liblantern.dll") +install(FILES "${LIBLANTERN_DLL}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" @@ -97,9 +94,11 @@ endif() # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) +if(EXISTS "${NATIVE_ASSETS_DIR}") + install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. diff --git a/windows/packaging/exe/inno_setup.iss b/windows/packaging/exe/inno_setup.iss index b597f1d158..6540da7c1b 100644 --- a/windows/packaging/exe/inno_setup.iss +++ b/windows/packaging/exe/inno_setup.iss @@ -285,8 +285,6 @@ Name: "{#ProgramDataDir}"; Permissions: users-modify [Files] Source: "{{SOURCE_DIR}}\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "{{SOURCE_DIR}}\\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "{{SOURCE_DIR}}\\lanternsvc.exe"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{autoprograms}\\{{DISPLAY_NAME}}"; Filename: "{app}\\{{EXECUTABLE_NAME}}"