From 3e9e04f68c4b013fc462e6191b7697c167650cea Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 29 Aug 2026 18:49:08 +0900 Subject: [PATCH] ci(release): require trusted Windows signing --- .github/workflows/release.yml | 30 ++- .gitlab-ci.yml | 22 ++ CHANGELOG.md | 1 + docs/deployment/release-guide.md | 9 +- .../ci/verify-windows-release-artifact.ps1 | 221 ++++++++++++++++++ 5 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/verify-windows-release-artifact.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e670a7..fbed353 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,13 +110,39 @@ jobs: npm run build --workspace=@d3ro/desktop - name: Package with Electron Builder (NSIS x64) + shell: pwsh run: | - cd apps/desktop - npx electron-builder --win --x64 --config electron-builder.yml + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:CSC_LINK)) { + throw 'WIN_CSC_LINK must contain the production code-signing PFX path, URL, or base64 payload.' + } + if ([string]::IsNullOrWhiteSpace($env:CSC_KEY_PASSWORD)) { + throw 'WIN_CSC_KEY_PASSWORD is required.' + } + if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT)) { + throw 'WIN_CSC_EXPECTED_SIGNER_SUBJECT is required.' + } + if ($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -match '(?i)Everything2EverythingDev') { + throw 'The local Everything2EverythingDev certificate is not a production signing identity.' + } + $releaseVersion = node -p "require('./release/product-version.json').version" + Push-Location apps/desktop + try { + npx electron-builder --win --x64 --config electron-builder.yml + if ($LASTEXITCODE -ne 0) { throw "electron-builder failed with exit code $LASTEXITCODE." } + } + finally { + Pop-Location + } + & scripts/ci/verify-windows-release-artifact.ps1 ` + -ExpectedVersion $releaseVersion ` + -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT ` + -ReleaseDirectory "apps/desktop/release/$releaseVersion" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.WIN_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + WIN_CSC_EXPECTED_SIGNER_SUBJECT: ${{ secrets.WIN_CSC_EXPECTED_SIGNER_SUBJECT }} - name: Upload Windows Build Artifacts uses: actions/upload-artifact@v4 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 78a090e..112a7fd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -206,10 +206,32 @@ package-windows: before_script: - npm ci script: + - | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_PFX_FILE) -or -not (Test-Path -LiteralPath $env:WIN_CSC_PFX_FILE -PathType Leaf)) { + throw 'WIN_CSC_PFX_FILE must be a protected GitLab file variable containing the production code-signing PFX.' + } + if ((Get-Item -LiteralPath $env:WIN_CSC_PFX_FILE).Length -le 0) { + throw 'WIN_CSC_PFX_FILE is empty.' + } + if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_KEY_PASSWORD)) { + throw 'WIN_CSC_KEY_PASSWORD must be a protected, masked GitLab variable.' + } + if ([string]::IsNullOrWhiteSpace($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT)) { + throw 'WIN_CSC_EXPECTED_SIGNER_SUBJECT must be a protected GitLab variable.' + } + if ($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -match '(?i)Everything2EverythingDev') { + throw 'The local Everything2EverythingDev certificate is not a production signing identity.' + } + $env:CSC_LINK = (Resolve-Path -LiteralPath $env:WIN_CSC_PFX_FILE).Path + $env:CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD - node scripts/ci/sync-version.mjs --check --tag "$CI_COMMIT_TAG" - npm run build --workspace=@d3ro/desktop - cd apps/desktop - npx electron-builder --win --x64 --config electron-builder.yml + - cd ../.. + - $releaseVersion = node -p "require('./release/product-version.json').version" + - '& scripts/ci/verify-windows-release-artifact.ps1 -ExpectedVersion $releaseVersion -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -ReleaseDirectory "apps/desktop/release/$releaseVersion"' artifacts: name: "d3ro-voice-windows-$CI_COMMIT_TAG" paths: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cae6c6..99a406c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed synthetic success fallbacks from protected STT and AI paths; provider and configuration failures now return explicit failures without creating fabricated user data. - Strengthened generated-document reporting so only the owner can report an existing document backed by immutable generation audit evidence. - Removed the repository-exposed desktop license private key and prefix-only paid-tier activation; production now accepts only Ed25519 licenses signed by the rotated external key. +- Made Windows stable packaging fail closed unless an external public-trust code-signing PFX is provided and both the NSIS installer and unpacked app pass exact-version, updater SHA-512, Authenticode `Valid`, and expected signer-subject verification; local self-signed development certificates are rejected. ### Fixed - Corrected realtime Edge Function model routing and type checks for team and enterprise tiers. diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md index fd4dbbf..427f8cf 100644 --- a/docs/deployment/release-guide.md +++ b/docs/deployment/release-guide.md @@ -12,6 +12,7 @@ | Android upload key | alias `d3ro-upload-20260821`, cert SHA-256 `4F:AC:69:24:...:15:2B:54` | external PKCS12·user-only ACL·Credential Manager·private-key readback GREEN; CI secret·복구 백업·AAB signer 대조 대기 | | release evidence | Ed25519 public `release/mobile-release-evidence-public.pem`, keyId `2797d3e6...4a890b7f` | external private key ACL·roundtrip GREEN; CI private-key secret·복구 백업 대기 | | desktop offline license | Ed25519 public `apps/desktop/resources/license/production-public.pem`, keyId `5c52b765...81a887f` | 새 전용 keypair·external private ACL·roundtrip·desktop production build GREEN; admin `ADMIN_LICENSE_PRIVATE_KEY` secret 주입 대기 | +| Windows Authenticode | external public-trust code-signing certificate | 현재 local `1.1.0` installer·unpacked app은 `NotSigned`; production PFX·CI secret·signed artifact GREEN 전까지 게시 금지 | | Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 | | AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 | | updater feed | `https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest` | public `latest.yml`은 아직 `0.2.1-alpha`; `1.1.0` 미배포 | @@ -38,6 +39,8 @@ authoritative release commit - 같은 gate는 desktop license public key가 Ed25519이고 `release/product-version.json`의 `desktopLicensePublicKeyId`와 일치하는지 검증한다. `electron.vite.config.ts`는 이 파일을 직접 읽으므로 누락·손상된 키로는 build가 시작되지 않는다. - `scripts/ci/publish-gitlab-release.mjs`는 버전별 패키지를 먼저 올리고, `latest` 파일에서 설치 자산 참조를 검증한 후 update metadata를 마지막에 게시한다. - Windows installer와 `latest.yml`은 필수다. macOS 산출물이 없는 Windows-only release를 의도했다면 그 판단을 release record에 남긴다. +- `package-windows`는 external public-trust code-signing PFX를 protected file variable `WIN_CSC_PFX_FILE`로, 암호와 exact certificate subject를 protected `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`로 받아야 한다. GitHub Release도 `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`가 모두 없으면 실패한다. +- `scripts/ci/verify-windows-release-artifact.ps1`는 installer와 unpacked app의 Authenticode `Valid`, exact signer subject, non-self-signed code-signing EKU, PE version, `latest.yml` path/size/SHA-512를 검증한다. 로컬 self-signed `Everything2EverythingDev`는 production 신뢰 인증서가 아니며 gate에서 명시적으로 거부한다. ## 자동 업데이트 계약 @@ -54,7 +57,8 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice 3. `latest.yml` URL/path가 같은 `latest` 경로의 실제 installer를 참조한다. 4. installer 파일명에 공백이 없다: `D3RO-Voice-Setup--x64.exe`. 5. metadata SHA-512와 다운로드한 installer가 일치한다. -6. 이전 실제 설치본이 feed를 탐지하고, 다운로드·재시작·버전 상승을 끝까지 완료한다. +6. installer와 `win-unpacked/D3RO Voice.exe` 모두 external public-trust certificate의 Authenticode `Valid`이고, signer subject가 protected CI identity와 정확히 일치한다. +7. 이전 실제 설치본이 feed를 탐지하고, 다운로드·재시작·버전 상승을 끝까지 완료한다. 2026-08-29 live `latest.yml`의 버전은 `0.2.1-alpha`다. 이는 updater endpoint가 응답한다는 증거일 뿐 `1.1.0` 게시 증거가 아니다. @@ -63,7 +67,7 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice 1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. 2. `CHANGELOG.md` `## [1.1.0] - 2026-08-29` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션이 없으면 실패해야 한다. 3. dirty/untracked 작업을 임의로 reset·clean하지 말고, release 범위만 검토 가능한 authoritative commit으로 보존한다. -4. 같은 commit에서 lint, typecheck, test, build, release metadata·security·artifact gate를 전부 GREEN으로 만든다. +4. 같은 commit에서 lint, typecheck, test, build, release metadata·security·artifact gate를 전부 GREEN으로 만든다. Windows는 production Authenticode PFX를 주입한 CI build와 `verify-windows-release-artifact.ps1` GREEN이 필수다. 5. desktop offline license를 제공한다면 external private key를 admin의 `ADMIN_LICENSE_PRIVATE_KEY` secret로 주입하고, 저장소 public key와 sign/verify roundtrip 및 발급 감사 로그를 확인한다. 6. 이전 버전보다 높은 태그 `v1.1.0`을 생성해 push한다. 태그는 게이트를 시작하는 후속 단계지 검증을 대체하지 않는다. 7. GitLab에서 package-windows, package-macos, publish-release와 의도한 mobile job 상태를 모두 확인한다. pending/stuck/skipped를 GREEN으로 기록하지 않는다. @@ -96,6 +100,7 @@ Desktop GitLab Release를 게시해도 Android production 출시가 자동으로 - `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT - `scripts/ci/sync-version.mjs` — 버전 면 동기화·검증 - `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증 +- `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate - `scripts/ci/publish-gitlab-release.mjs` — registry·Release·updater feed publisher - `apps/desktop/src/main/update-feed.ts` — runtime updater URL SSOT - `apps/desktop/electron-builder.yml` — builder publish URL·artifact contract diff --git a/scripts/ci/verify-windows-release-artifact.ps1 b/scripts/ci/verify-windows-release-artifact.ps1 new file mode 100644 index 0000000..ee8affa --- /dev/null +++ b/scripts/ci/verify-windows-release-artifact.ps1 @@ -0,0 +1,221 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$')] + [string]$ExpectedVersion, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ExpectedSignerSubject, + + [Parameter(Mandatory = $false)] + [string]$ReleaseDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Fail-Verification { + param([Parameter(Mandatory = $true)][string]$Reason) + throw "windows_release_verification_failed:$Reason" +} + +function Get-RequiredYamlValue { + param( + [Parameter(Mandatory = $true)][string]$Text, + [Parameter(Mandatory = $true)][string]$Pattern, + [Parameter(Mandatory = $true)][string]$Name + ) + + $matches = [regex]::Matches($Text, $Pattern) + if ($matches.Count -ne 1) { + Fail-Verification "latest_yml_${Name}_count_$($matches.Count)" + } + + return $matches[0].Groups[1].Value.Trim().Trim('"').Trim("'") +} + +function Get-Sha512Base64 { + param([Parameter(Mandatory = $true)][string]$Path) + + $stream = [System.IO.File]::OpenRead($Path) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + try { + return [Convert]::ToBase64String($sha512.ComputeHash($stream)) + } + finally { + $sha512.Dispose() + $stream.Dispose() + } +} + +function Assert-PeVersion { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][version]$Expected + ) + + $versionInfo = (Get-Item -LiteralPath $Path).VersionInfo + foreach ($entry in @( + @{ Name = 'file_version'; Value = $versionInfo.FileVersion }, + @{ Name = 'product_version'; Value = $versionInfo.ProductVersion } + )) { + $value = [string]$entry.Value + if ($value -notmatch '^\d+\.\d+\.\d+(?:\.0)?$') { + Fail-Verification "${Label}_$($entry.Name)_invalid" + } + + $actual = [version]$value + if ($actual.Major -ne $Expected.Major -or + $actual.Minor -ne $Expected.Minor -or + $actual.Build -ne $Expected.Build -or + ($actual.Revision -notin @(-1, 0))) { + Fail-Verification "${Label}_$($entry.Name)_mismatch" + } + } + + if ($versionInfo.ProductName -cne 'D3RO Voice') { + Fail-Verification "${Label}_product_name_mismatch" + } +} + +function Assert-ProductionSignature { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][string]$ExpectedSubject + ) + + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + Fail-Verification "${Label}_authenticode_$($signature.Status)" + } + + $certificate = $signature.SignerCertificate + if ($null -eq $certificate) { + Fail-Verification "${Label}_signer_certificate_missing" + } + + $actualSubject = $certificate.Subject.Trim() + if (-not [string]::Equals($actualSubject, $ExpectedSubject, [StringComparison]::OrdinalIgnoreCase)) { + Fail-Verification "${Label}_signer_subject_mismatch" + } + + if ([string]::Equals($certificate.Subject, $certificate.Issuer, [StringComparison]::OrdinalIgnoreCase)) { + Fail-Verification "${Label}_self_signed_certificate" + } + + $now = [DateTime]::UtcNow + if ($certificate.NotBefore.ToUniversalTime() -gt $now -or $certificate.NotAfter.ToUniversalTime() -le $now) { + Fail-Verification "${Label}_signer_certificate_not_current" + } + + $codeSigningOid = '1.3.6.1.5.5.7.3.3' + $ekuOids = @() + foreach ($extension in $certificate.Extensions) { + if ($extension -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + foreach ($usage in $extension.EnhancedKeyUsages) { + $ekuOids += $usage.Value + } + } + } + if ($ekuOids -notcontains $codeSigningOid) { + Fail-Verification "${Label}_code_signing_eku_missing" + } + + return $certificate +} + +$expectedSubject = $ExpectedSignerSubject.Trim() +if ([string]::IsNullOrWhiteSpace($expectedSubject)) { + Fail-Verification 'expected_signer_subject_empty' +} +if ($expectedSubject -match '(?i)Everything2EverythingDev') { + Fail-Verification 'development_signer_subject_forbidden' +} + +if ([string]::IsNullOrWhiteSpace($ReleaseDirectory)) { + $ReleaseDirectory = Join-Path $PSScriptRoot "../../apps/desktop/release/$ExpectedVersion" +} + +if (-not (Test-Path -LiteralPath $ReleaseDirectory -PathType Container)) { + Fail-Verification 'release_directory_missing' +} +$releasePath = (Resolve-Path -LiteralPath $ReleaseDirectory).Path +if ((Split-Path -Leaf $releasePath) -cne $ExpectedVersion) { + Fail-Verification 'release_directory_version_mismatch' +} + +$installerName = "D3RO-Voice-Setup-$ExpectedVersion-x64.exe" +$installerPath = Join-Path $releasePath $installerName +$latestPath = Join-Path $releasePath 'latest.yml' +$appPath = Join-Path $releasePath 'win-unpacked/D3RO Voice.exe' + +foreach ($requiredFile in @($installerPath, $latestPath, $appPath)) { + if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) { + Fail-Verification "required_file_missing_$(Split-Path -Leaf $requiredFile)" + } +} + +$installers = @(Get-ChildItem -LiteralPath $releasePath -File -Filter 'D3RO-Voice-Setup-*-x64.exe') +if ($installers.Count -ne 1 -or $installers[0].Name -cne $installerName) { + Fail-Verification 'installer_set_not_exact' +} + +$latestText = [System.IO.File]::ReadAllText($latestPath) +$metadataVersion = Get-RequiredYamlValue $latestText '(?m)^version:\s*([^\r\n#]+?)\s*$' 'version' +$metadataUrl = Get-RequiredYamlValue $latestText '(?m)^\s{2}-\s+url:\s*([^\r\n#]+?)\s*$' 'file_url' +$metadataFileSha512 = Get-RequiredYamlValue $latestText '(?m)^\s{4}sha512:\s*([^\r\n#]+?)\s*$' 'file_sha512' +$metadataSizeText = Get-RequiredYamlValue $latestText '(?m)^\s{4}size:\s*([^\r\n#]+?)\s*$' 'file_size' +$metadataPath = Get-RequiredYamlValue $latestText '(?m)^path:\s*([^\r\n#]+?)\s*$' 'path' +$metadataSha512 = Get-RequiredYamlValue $latestText '(?m)^sha512:\s*([^\r\n#]+?)\s*$' 'sha512' + +if ($metadataVersion -cne $ExpectedVersion) { + Fail-Verification 'latest_yml_version_mismatch' +} +if ($metadataUrl -cne $installerName -or $metadataPath -cne $installerName) { + Fail-Verification 'latest_yml_installer_reference_mismatch' +} + +[long]$metadataSize = 0 +if (-not [long]::TryParse($metadataSizeText, [ref]$metadataSize) -or $metadataSize -le 0) { + Fail-Verification 'latest_yml_size_invalid' +} +$installerSize = (Get-Item -LiteralPath $installerPath).Length +if ($metadataSize -ne $installerSize) { + Fail-Verification 'latest_yml_size_mismatch' +} + +$installerSha512 = Get-Sha512Base64 $installerPath +if ($metadataFileSha512 -cne $installerSha512 -or $metadataSha512 -cne $installerSha512) { + Fail-Verification 'latest_yml_sha512_mismatch' +} + +$expectedPeVersion = [version]"$ExpectedVersion.0" +Assert-PeVersion $installerPath 'installer' $expectedPeVersion +Assert-PeVersion $appPath 'unpacked_app' $expectedPeVersion + +$installerCertificate = Assert-ProductionSignature $installerPath 'installer' $expectedSubject +$appCertificate = Assert-ProductionSignature $appPath 'unpacked_app' $expectedSubject +if ($installerCertificate.Thumbprint -cne $appCertificate.Thumbprint) { + Fail-Verification 'signer_thumbprint_mismatch' +} + +$evidence = [ordered]@{ + schemaVersion = 1 + version = $ExpectedVersion + installer = $installerName + installerSize = $installerSize + installerSha512 = $installerSha512 + unpackedApp = 'win-unpacked/D3RO Voice.exe' + authenticodeStatus = 'Valid' + signerSubject = $installerCertificate.Subject + signerIssuer = $installerCertificate.Issuer + signerThumbprint = $installerCertificate.Thumbprint + verifiedAtUtc = [DateTime]::UtcNow.ToString('o') +} + +$evidence | ConvertTo-Json -Depth 3