ci(release): require trusted Windows signing

This commit is contained in:
Yun Chan 2026-08-29 18:49:08 +09:00
parent 5205dcdfa9
commit 3e9e04f68c
5 changed files with 279 additions and 4 deletions

View file

@ -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

View file

@ -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:

View file

@ -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.

View file

@ -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-<version>-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

View file

@ -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