From f9a96cba0c59ddf23467848443caf51d8a5d8584 Mon Sep 17 00:00:00 2001 From: Aleksandr Muleev Date: Wed, 14 Oct 2026 18:27:26 +0300 Subject: [PATCH] Prepare free SignPath code signing for Windows releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подготовлена бесплатная подпись собственных выпусков AMSignal через SignPath Foundation. Подключение сервиса и фактическая подпись остаются зависимыми от одобрения проекта и ручного разрешения каждого запроса владельцем. Добавлены Windows VERSIONINFO для GUI и CLI, единые имя продукта и текстовая версия, проверенные числовые компоненты и исходные имена EXE. Обе PowerShell- сборки и Windows Make включают эти ресурсы; Linux-сборка не требует windres. Новый ручной workflow проверяет release tag из main, собирает и тестирует код на GitHub-hosted Windows, загружает неизменяемый артефакт и передаёт его ID в SignPath. Внешние Actions закреплены по SHA, права записи выдаются только созданию черновика выпуска, ключ подписи не размещается в репозитории. После подписи проверяются доверие Windows, издатель SignPath Foundation, временная метка, версии и совпадение сертификата GUI/CLI. Упаковка принимает проверенные EXE из отдельного каталога, считает SHA-256 по итоговым байтам, сохраняет документы/политику и не перезаписывает существующие архивы и выпуски. Добавлены ограничивающая XML-конфигурация, честная политика с текущим pending- статусом, инструкция активации и готовая неотправленная заявка. Обновлены три README, AGENTS.md и карта архитектуры. Численный код и GUI-поведение не менялись. Проверка: обе штатные PowerShell-сборки, 25 проверок метаданных/упаковки/ контрольных сумм, разбор PowerShell AST и XML, actionlint, запуск CLI FFT N=4096. Положительная криптографическая проверка SignPath требует одобрения и доступа к сервису; самоподписанный сертификат для имитации результата не создавался. --- .github/workflows/release-signpath.yml | 164 +++++++++++++++++++++++++ AGENTS.md | 14 +++ AM_logo.rc | 1 + AM_version.rc | 41 +++++++ Makefile | 24 +++- README.md | 6 + README_EN.md | 6 + README_RU.md | 6 + build_cli.ps1 | 5 +- build_gui.ps1 | 4 +- build_version_resource.ps1 | 27 ++++ docs/ARCHITECTURE.md | 36 ++++++ docs/CODE_SIGNING.md | 114 +++++++++++++++++ docs/SIGNPATH_APPLICATION.md | 47 +++++++ docs/signpath-artifact.xml | 20 +++ package_release.ps1 | 49 ++++++-- tests/release_signing.ps1 | 46 +++++++ verify_release_binaries.ps1 | 33 +++++ 18 files changed, 624 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/release-signpath.yml create mode 100644 AM_version.rc create mode 100644 build_version_resource.ps1 create mode 100644 docs/CODE_SIGNING.md create mode 100644 docs/SIGNPATH_APPLICATION.md create mode 100644 docs/signpath-artifact.xml create mode 100644 tests/release_signing.ps1 create mode 100644 verify_release_binaries.ps1 diff --git a/.github/workflows/release-signpath.yml b/.github/workflows/release-signpath.yml new file mode 100644 index 0000000..b38d3ce --- /dev/null +++ b/.github/workflows/release-signpath.yml @@ -0,0 +1,164 @@ +name: Signed release (SignPath Foundation) + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag on main (for example v0.17.0-rc.4) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: signpath-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + build: + if: github.repository == 'almuleev/AMSignal' && github.ref == 'refs/heads/main' + runs-on: windows-latest + timeout-minutes: 40 + outputs: + version: ${{ steps.source.outputs.version }} + commit: ${{ steps.source.outputs.commit }} + artifact_id: ${{ steps.unsigned.outputs.artifact-id }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify configuration and immutable release source + id: source + shell: pwsh + env: + REQUESTED_TAG: ${{ inputs.tag }} + SIGNPATH_ENABLED: ${{ vars.SIGNPATH_ENABLED }} + SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }} + SIGNPATH_POLICY_SLUG: ${{ vars.SIGNPATH_POLICY_SLUG }} + SIGNPATH_CONFIGURATION_SLUG: ${{ vars.SIGNPATH_CONFIGURATION_SLUG }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:SIGNPATH_ENABLED -ne 'true') { throw 'SignPath is not activated. See docs/CODE_SIGNING.md.' } + foreach ($name in @('SIGNPATH_ORGANIZATION_ID','SIGNPATH_PROJECT_SLUG','SIGNPATH_POLICY_SLUG','SIGNPATH_CONFIGURATION_SLUG')) { + if (-not [Environment]::GetEnvironmentVariable($name)) { throw "Missing repository variable: $name" } + } + if ($env:REQUESTED_TAG -notmatch '^v(\d+\.\d+\.\d+(?:-[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*)?)$') { throw 'Invalid release tag.' } + $version = $Matches[1] + $ref = 'refs/tags/' + $env:REQUESTED_TAG + $commit = git rev-parse --verify ($ref + '^{commit}') + if ($LASTEXITCODE -ne 0) { throw 'Release tag does not exist.' } + git merge-base --is-ancestor $commit origin/main + if ($LASTEXITCODE -ne 0) { throw 'Release source is not part of main.' } + git checkout --detach $commit + if ($LASTEXITCODE -ne 0) { throw 'Cannot check out release source.' } + if (-not (Test-Path docs/signpath-artifact.xml)) { throw 'This tag predates the signing integration. Use a new release tag.' } + "version=$version" >> $env:GITHUB_OUTPUT + "commit=$commit" >> $env:GITHUB_OUTPUT + - uses: msys2/setup-msys2@ec48f7c5447b3140e2b088413ae3a55687bccb6e # v2 + with: + msystem: UCRT64 + update: true + install: >- + make + git + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-binutils + - name: Core and GUI regressions + shell: msys2 {0} + run: make test test-gui CXX=g++ + - name: Production GUI and CLI + shell: msys2 {0} + run: | + powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./build_cli.ps1 || exit $? + powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./build_gui.ps1 || exit $? + - name: Check metadata and stage only project executables + shell: pwsh + env: + RELEASE_VERSION: ${{ steps.source.outputs.version }} + run: | + ./verify_release_binaries.ps1 -InputDirectory . -Version $env:RELEASE_VERSION + ./tests/release_signing.ps1 -InputDirectory . -Version $env:RELEASE_VERSION + New-Item -ItemType Directory dist/signpath-input | Out-Null + Copy-Item -LiteralPath "AMSignal-${env:RELEASE_VERSION}-x64.exe", 'lvm_reader.exe' -Destination dist/signpath-input + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + id: unsigned + with: + name: signpath-input + path: dist/signpath-input/* + if-no-files-found: error + retention-days: 7 + + sign: + needs: build + runs-on: windows-latest + timeout-minutes: 75 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.build.outputs.commit }} + persist-credentials: false + - name: Submit request and wait for maintainer approval in SignPath + uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ vars.SIGNPATH_POLICY_SLUG }} + artifact-configuration-slug: ${{ vars.SIGNPATH_CONFIGURATION_SLUG }} + github-artifact-id: ${{ needs.build.outputs.artifact_id }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: signed + parameters: | + version: ${{ toJSON(needs.build.outputs.version) }} + - name: Verify signatures, then package and hash the signed bytes + shell: pwsh + env: + RELEASE_VERSION: ${{ needs.build.outputs.version }} + run: | + ./package_release.ps1 -InputDirectory signed -Version $env:RELEASE_VERSION -OutputDir dist/signed/package -RequireSignature -Zip + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verified-signed-release + path: | + dist/signed/*.zip + dist/signed/*.sha256 + dist/signed/package/*.exe + if-no-files-found: error + retention-days: 30 + + draft-release: + needs: [build, sign] + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: verified-signed-release + path: release-files + - name: Create a draft with verified signed assets + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + $ErrorActionPreference = 'Stop' + $existing = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json tagName 2>$null + if ($LASTEXITCODE -eq 0) { throw 'Release already exists. Its assets will not be overwritten.' } + $assets = @(Get-ChildItem release-files -Recurse -File | ForEach-Object FullName) + $notes = @('Signed Windows GUI and CLI; SHA-256 checksums attached.', '', + 'Code signing policy: https://github.com/almuleev/AMSignal/blob/main/docs/CODE_SIGNING.md', '', + 'Review and complete the release notes before publishing this draft.') + [IO.File]::WriteAllLines('release-notes.md', $notes) + $arguments = @('release','create',$env:RELEASE_TAG,'--repo',$env:GITHUB_REPOSITORY, + '--verify-tag','--draft','--title',"AMSignal $env:RELEASE_TAG",'--notes-file','release-notes.md') + if ($env:RELEASE_TAG.Contains('-')) { $arguments += '--prerelease' } + & gh @arguments @assets + if ($LASTEXITCODE -ne 0) { throw 'Could not create release draft.' } diff --git a/AGENTS.md b/AGENTS.md index f0452b8..b20d86b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,20 @@ ## Быстрая карта +- Бесплатная подпись (2026-10-14): release-signpath.yml собирает только чистый + tag из main на GitHub-hosted Windows, передаёт два собственных EXE в SignPath + и ждёт ручного одобрения. Подключение Foundation ещё не одобрено; не объявляй + текущие EXE подписанными. AM_version.rc/build_version_resource.ps1 добавляют + одинаковые ProductName/ProductVersion/FileVersion в GUI и CLI, включая Make. + verify_release_binaries.ps1 требует Valid Authenticode, SignPath Foundation, + timestamp и общий сертификат; package_release.ps1 -RequireSignature берёт + только проверенные EXE из InputDirectory и считает SHA-256 после подписи. + Старые выпуски не перезаписываются, новый создаётся только draft. Политика, + настройки и ограничения — docs/CODE_SIGNING.md; заявка — + docs/SIGNPATH_APPLICATION.md, XML — docs/signpath-artifact.xml. + tests/release_signing.ps1: 25 проверок метаданных, отказа unsigned, версий, + безопасных путей, ZIP и контрольных сумм. Численный код не менялся. + - Предварительный выпуск `v0.17.0-rc.3` (2026-10-14) объединяет накопленные исправления просмотра, AMSOIDX2/склейку, адаптивные RAM/диск ресурсы и аудит крайних случаев. Полное двуязычное описание — `docs/releases/v0.17.0-rc.3.md`; diff --git a/AM_logo.rc b/AM_logo.rc index 4e07878..cbcc7bb 100644 --- a/AM_logo.rc +++ b/AM_logo.rc @@ -1 +1,2 @@ 101 ICON "AM_logo.ico" +#include "AM_version.rc" diff --git a/AM_version.rc b/AM_version.rc new file mode 100644 index 0000000..ee0c1d8 --- /dev/null +++ b/AM_version.rc @@ -0,0 +1,41 @@ +#include +#ifndef AM_VERSION_STRING +#define AM_VERSION_STRING "dev" +#endif +#ifndef AM_VERSION_NUMBERS +#define AM_VERSION_NUMBERS 0,0,0,0 +#endif +#ifndef AM_ORIGINAL_FILENAME +#define AM_ORIGINAL_FILENAME "AMSignal.exe" +#endif +1 VERSIONINFO + FILEVERSION AM_VERSION_NUMBERS + PRODUCTVERSION AM_VERSION_NUMBERS + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK + FILEFLAGS 0 + FILEOS VOS_NT_WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "AMSignal contributors" +#ifdef AM_CLI + VALUE "FileDescription", "AMSignal command-line signal analysis" +#else + VALUE "FileDescription", "AMSignal desktop signal analysis" +#endif + VALUE "FileVersion", AM_VERSION_STRING + VALUE "ProductName", "AMSignal" + VALUE "ProductVersion", AM_VERSION_STRING + VALUE "OriginalFilename", AM_ORIGINAL_FILENAME + VALUE "LegalCopyright", "AMSignal contributors; GNU GPLv3" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END +END diff --git a/Makefile b/Makefile index 1459142..8d5e5fd 100644 --- a/Makefile +++ b/Makefile @@ -22,11 +22,16 @@ GUI_TEST_OBJECT := .build/make_gui/gui_regression.o BENCH_BIN := tests/perf_benchmark.exe GUI_FLAGS := $(CPPFLAGS) $(CXXFLAGS) -DAPP_VERSION_W=L\"$(VERSION)\" -I. GUI_RES := AM_logo.o +VERSION_NUMBERS := $(shell printf '%s' '$(RELEASE_VERSION)' | sed -nE 's/^([0-9]+)\.([0-9]+)\.([0-9]+)([.+-].*)?$$/\1,\2,\3,0/p') +ifeq ($(VERSION_NUMBERS),) + VERSION_NUMBERS := 0,0,0,0 +endif ifeq ($(OS),Windows_NT) BIN := $(TARGET).exe TEST_BIN := tests/run_tests.exe GUI_BIN := AMSignal-$(RELEASE_VERSION)-x64.exe + CLI_RES := .build/cli/version.o else BIN := $(TARGET) TEST_BIN := tests/run_tests @@ -41,8 +46,15 @@ all: $(BIN) main.o: FORCE FORCE: -$(BIN): $(APP_OBJ) - $(CXX) $(CXXFLAGS) -o $@ $(APP_OBJ) $(LDFLAGS) +$(BIN): $(APP_OBJ) $(CLI_RES) + $(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS) + +ifeq ($(OS),Windows_NT) +$(CLI_RES): AM_version.rc FORCE + mkdir -p .build/cli + printf '%s\n' '#define AM_VERSION_STRING "$(RELEASE_VERSION)"' '#define AM_VERSION_NUMBERS $(VERSION_NUMBERS)' '#define AM_CLI 1' '#define AM_ORIGINAL_FILENAME "lvm_reader.exe"' '#include "AM_version.rc"' > .build/cli/version.rc + $(WINDRES) -I. -O coff -i .build/cli/version.rc -o $@ +endif %.o: %.cpp $(HDRS) $(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@ @@ -87,11 +99,13 @@ $(TEST_BIN): tests/run_tests.cpp tests/recording_overview_tests.cpp tests/record # Win32 import libraries. On Windows you can also run: powershell ./build_gui.ps1 gui: $(GUI_BIN) -$(GUI_RES): AM_logo.rc AM_logo.ico - $(WINDRES) -O coff -i $< -o $@ +$(GUI_RES): AM_logo.rc AM_version.rc AM_logo.ico FORCE + mkdir -p .build + printf '%s\n' '#define AM_VERSION_STRING "$(RELEASE_VERSION)"' '#define AM_VERSION_NUMBERS $(VERSION_NUMBERS)' '#define AM_ORIGINAL_FILENAME "$(GUI_BIN)"' '#include "AM_logo.rc"' > .build/logo-version.rc + $(WINDRES) -I. -O coff -i .build/logo-version.rc -o $@ $(GUI_BIN): $(GUI_OBJECTS) $(GUI_RES) $(CXX) -municode -mwindows -o $@ $^ $(LDFLAGS) -lcomdlg32 -lgdi32 -luser32 -lgdiplus -lcomctl32 -luxtheme -ladvapi32 -lshell32 -lole32 -luuid clean: - rm -f $(APP_OBJ) $(BIN) $(TEST_BIN) $(GUI_BIN) $(GUI_RES) $(GUI_OBJECTS) $(GUI_OBJECTS:.o=.d) $(GUI_TEST_OBJECT) $(GUI_TEST_OBJECT:.o=.d) $(BENCH_BIN) tests/gui_regression.exe + rm -f $(APP_OBJ) $(BIN) $(TEST_BIN) $(GUI_BIN) $(GUI_RES) $(CLI_RES) $(GUI_OBJECTS) $(GUI_OBJECTS:.o=.d) $(GUI_TEST_OBJECT) $(GUI_TEST_OBJECT:.o=.d) $(BENCH_BIN) tests/gui_regression.exe diff --git a/README.md b/README.md index 66a9714..3e2619c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ The project includes two front ends built on one shared parsing and analysis cor The interface screenshots above show the real application UI. The graph previews below were generated from the bundled sample dataset in [`lvm_files_for_tests`](lvm_files_for_tests). +## Code signing policy + +Free SignPath Foundation signing integration is prepared; enrollment and the +first signed release are pending. Current downloads are not automatically signed. +See the [code signing policy and activation steps](docs/CODE_SIGNING.md). + ## Quick Start ### For end users diff --git a/README_EN.md b/README_EN.md index be30035..82a1dbd 100644 --- a/README_EN.md +++ b/README_EN.md @@ -103,6 +103,12 @@ handled separately by filling with the channel mean, without shifting channels. The screenshots above show the real application interface. The graph previews below are based on the bundled sample input files from [`lvm_files_for_tests`](lvm_files_for_tests). +## Code signing policy + +Free SignPath Foundation signing integration is prepared; provider acceptance +and the first signed release are pending. See the [code signing policy](docs/CODE_SIGNING.md). +Existing downloads must not be assumed to have a trusted signature. + ## Quick Start ### Download a ready-to-run build diff --git a/README_RU.md b/README_RU.md index 14a5b3a..96d17f3 100644 --- a/README_RU.md +++ b/README_RU.md @@ -107,6 +107,12 @@ FFT принимает неравномерные и округлённые ме Скриншоты выше показывают реальный интерфейс программы. Превью графиков ниже построены по реальным данным из папки [`lvm_files_for_tests`](lvm_files_for_tests). +## Code signing policy / Подпись программы + +Подготовлена бесплатная интеграция SignPath Foundation. Одобрение проекта и +первый подписанный выпуск ещё требуются. [Политика подписи](docs/CODE_SIGNING.md) +описывает подключение и проверку; прежние скачивания не становятся подписанными автоматически. + ## Быстрый старт ### Готовая сборка diff --git a/build_cli.ps1 b/build_cli.ps1 index fb44662..11ad334 100644 --- a/build_cli.ps1 +++ b/build_cli.ps1 @@ -7,6 +7,9 @@ if (-not (Get-Command g++ -ErrorAction SilentlyContinue)) { $version = git -c core.excludesFile= describe --tags --always --dirty 2>$null if (-not $version) { $version = 'dev' } $versionDefine = '-DAPP_VERSION=\"' + $version + '\"' -& g++ -std=c++17 -O2 -Wall -Wextra -finput-charset=UTF-8 -static $versionDefine -o lvm_reader.exe main.cpp lvm_parser.cpp recording_source.cpp recording_overview.cpp overview_worker.cpp disk_cache.cpp scan_index_cache.cpp data_io.cpp filter_engine.cpp spectrum_worker.cpp fft.cpp analysis.cpp disk_spectrum.cpp frf_stream.cpp frf_analysis.cpp -lshell32 +. (Join-Path $PSScriptRoot 'build_version_resource.ps1') +$resourceObject = Join-Path $PSScriptRoot '.build/cli/version.o' +Build-VersionResource -Version $version -OriginalFilename 'lvm_reader.exe' -OutputPath $resourceObject -Cli +& g++ -std=c++17 -O2 -Wall -Wextra -finput-charset=UTF-8 -static $versionDefine -o lvm_reader.exe main.cpp lvm_parser.cpp recording_source.cpp recording_overview.cpp overview_worker.cpp disk_cache.cpp scan_index_cache.cpp data_io.cpp filter_engine.cpp spectrum_worker.cpp fft.cpp analysis.cpp disk_spectrum.cpp frf_stream.cpp frf_analysis.cpp $resourceObject -lshell32 if ($LASTEXITCODE -ne 0) { throw "CLI build failed (exit $LASTEXITCODE)." } Write-Host "Built lvm_reader.exe ($version)" diff --git a/build_gui.ps1 b/build_gui.ps1 index ab42012..da51710 100644 --- a/build_gui.ps1 +++ b/build_gui.ps1 @@ -54,8 +54,8 @@ if ($Test) { $outName = Join-Path ([IO.Path]::GetFullPath($OutputDirectory)) $outName } $resourceObject = Join-Path $objectDir 'AM_logo_res.o' - & windres -O coff -i AM_logo.rc -o $resourceObject - if ($LASTEXITCODE -ne 0) { throw 'Resource compilation failed.' } + . (Join-Path $PSScriptRoot 'build_version_resource.ps1') + Build-VersionResource -Version $version -OriginalFilename "AMSignal-$releaseVersion-x64.exe" -OutputPath $resourceObject & g++ -municode -static -mwindows -o $outName $resourceObject @objects @libraries } if ($LASTEXITCODE -ne 0) { throw "Link failed: $outName (exit $LASTEXITCODE)." } diff --git a/build_version_resource.ps1 b/build_version_resource.ps1 new file mode 100644 index 0000000..c9fb40e --- /dev/null +++ b/build_version_resource.ps1 @@ -0,0 +1,27 @@ +# Shared Windows metadata for the two production executables. No runtime code. +function Build-VersionResource { + param([string]$Version, [string]$OriginalFilename, [string]$OutputPath, [switch]$Cli) + $releaseVersion = $Version -replace '^v', '' + if ($releaseVersion -notmatch '^[A-Za-z0-9][A-Za-z0-9.+-]{0,127}$' -or + $OriginalFilename -notmatch '^[A-Za-z0-9][A-Za-z0-9_.+-]*\.exe$') { + throw 'Unsafe executable version or filename.' + } + $numbers = '0,0,0,0' + if ($releaseVersion -match '^(\d+)\.(\d+)\.(\d+)(?:[.+-]|$)') { + $parts = @($Matches[1], $Matches[2], $Matches[3]) + foreach ($part in $parts) { if ([uint64]$part -gt 65535) { throw 'Windows version component exceeds 65535.' } } + $numbers = ($parts -join ',') + ',0' + } + New-Item -ItemType Directory -Force -Path (Split-Path $OutputPath -Parent) | Out-Null + $resource = if ($Cli) { 'AM_version.rc' } else { 'AM_logo.rc' } + # Write literal C strings once; avoid quoting through PowerShell -> windres -> cpp. + $lines = @(('#define AM_VERSION_STRING "' + $releaseVersion + '"'), + ('#define AM_VERSION_NUMBERS ' + $numbers), + ('#define AM_ORIGINAL_FILENAME "' + $OriginalFilename + '"')) + if ($Cli) { $lines += '#define AM_CLI 1' } + $lines += '#include "' + $resource + '"' + $generated = $OutputPath + '.rc' + [IO.File]::WriteAllLines($generated, $lines, [Text.Encoding]::ASCII) + & windres -I $PSScriptRoot -O coff -i $generated -o $OutputPath + if ($LASTEXITCODE -ne 0) { throw "Version resource compilation failed ($LASTEXITCODE)." } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 098ea77..aed4438 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,6 +6,42 @@ LVM/TXT/CSV и анализа сигналов. Ниже имена модуле ## Поток данных и основные связи +### Бесплатная подпись Windows-выпусков (2026-10-14) + +AM_version.rc задаёт Windows VERSIONINFO для GUI/CLI: ProductName AMSignal, +текстовая версия из git describe, одинаковые ProductVersion/FileVersion и +OriginalFilename. GUI включает его через AM_logo.rc; CLI получает отдельный +resource object. build_version_resource.ps1 генерирует ASCII RC с проверенными +значениями, избегая двойного quoting через PowerShell/windres/cpp. Windows Make +генерирует те же ресурсы; Linux CLI не требует Win32 resource compiler. + +release-signpath.yml запускается вручную на main, принимает существующий +release tag из истории main, собирает/проверяет исходники на GitHub-hosted +Windows и передаёт ID неизменяемого upload-artifact в SignPath. Actions +закреплены по SHA; secret доступен только подписи, contents:write — только +созданию draft. SignPath Foundation требует ручного одобрения каждого запроса. +После возврата файлов verify_release_binaries.ps1 проверяет версии, Valid +Authenticode, издателя SignPath Foundation, timestamp и одинаковый сертификат +GUI/CLI. package_release.ps1 -InputDirectory ... -RequireSignature -Zip +копирует эти EXE, считает SHA256SUMS/asset .sha256 по итоговым байтам и создаёт +ZIP. Подписанные EXE не изменяются. Существующие каталоги, архивы и releases +не перезаписываются; результат GitHub — draft с EXE/ZIP/checksums. + +Подключение бесплатного сервиса и сертификата ещё не одобрено. Pipeline нельзя +считать фактической подписью: нужны принятие Foundation, подключение GitHub App, +реальные SIGNPATH_* переменные/secret, MFA и человеческое approval. Политика и +честный pending-статус — docs/CODE_SIGNING.md, ссылки есть в трёх README; +docs/SIGNPATH_APPLICATION.md содержит готовую неотправленную заявку. +docs/signpath-artifact.xml ограничивает подпись ровно двумя EXE AMSignal с +одинаковой версией. Подробности ресурсов и настройки не влияют на FFT/FRF. + +Новая проверка tests/release_signing.ps1 даёт 25 условий: реальные метаданные +обоих EXE, отказ неподписанных при RequireSignature, неверной версии/пути, +неизменность существующего ZIP, наличие policy и точность SHA-256/ZIP-копий. +Обе штатные PowerShell-сборки прошли; локальный результат — dist/signpath-build. +Криптографическая успешная подпись/загрузка из сервиса требует его активации и +не имитируется самоподписанным сертификатом. Конкретные инструкции — в policy. + ### Предварительный выпуск v0.17.0-rc.3 (2026-10-14) Накопленные изменения после rc.2, актуальная политика RAM/диск, AMSOIDX2, diff --git a/docs/CODE_SIGNING.md b/docs/CODE_SIGNING.md new file mode 100644 index 0000000..31d859e --- /dev/null +++ b/docs/CODE_SIGNING.md @@ -0,0 +1,114 @@ +# Code signing policy + +## Current status / Текущее состояние + +Integration for **free SignPath Foundation signing** is prepared. Enrollment, +certificate access and the first signed release are **not yet approved**. +Existing GitHub downloads and local builds must not be described as signed +unless their Windows Authenticode signature actually verifies. + +Подготовлена интеграция бесплатной подписи SignPath Foundation. Одобрение +проекта и подключение сертификата ещё требуются. Настройка скриптов сама по +себе не подписывает уже выпущенные EXE и не убирает предупреждения Windows. + +After acceptance, the signing attribution is: +**Free code signing provided by [SignPath.io](https://signpath.io/), certificate +by [SignPath Foundation](https://signpath.org/).** This is a planned provider, +not a claim of an existing sponsorship or a certificate already issued. + +## Team and approval + +- Author, committer, reviewer and signing approver: repository maintainer + [almuleev](https://github.com/almuleev), subject to confirmation during enrollment. +- External contributions, including release build scripts, require maintainer review. +- Each production signing request requires manual approval by the maintainer in + SignPath. An automated build does not approve its own request. +- Maintainers must enable MFA for GitHub and SignPath, as required by the provider. + +## Privacy and local system changes + +AMSignal does not upload recordings, collect telemetry or contact a signing +service at runtime. Files are read from and exported to destinations chosen +by the user, including explicitly selected network locations. Signing happens +on the release server and sends executable build artifacts, not measurement data. +Windows may independently access certificate/SmartScreen services when verifying +a downloaded executable; that is a Windows security function. + +Before running the portable GUI, note that it registers `.AMSig` for the current +Windows user, writes local settings, and can create bounded analysis caches. +File association uses `HKCU\Software\Classes\.AMSig` and +`HKCU\Software\Classes\AMSignal.Project`; no administrator rights are needed. +Removing the portable program folder removes its executables. Settings/caches +may remain; change the `.AMSig` default application in Windows Settings if +needed. Remove the two registry entries manually only if they still refer to +AMSignal and you intend to remove its association. Keep exported recordings. + +Программа не отправляет записи и телеметрию. Подпись выполняется на стороне +выпуска и не замедляет FFT/FRF/фильтр. При запуске Windows может проверять +сертификат. До первого запуска учитывайте регистрацию расширения `.AMSig`, +локальные настройки и кэш; это описано выше вместе с удалением программы. + +## Activation by the repository owner + +1. Publish/review this integration, then apply at + [SignPath Foundation](https://signpath.org/apply.html), using the ready + [application draft](SIGNPATH_APPLICATION.md). Acceptance is the provider's + decision; the public GPLv3 repository meets basic license/visibility conditions. +2. After acceptance, connect the SignPath GitHub App to **almuleev/AMSignal**, + configure a GitHub trusted build system and a production signing policy + requiring manual maintainer approval. Use public-trust signing, not a test + or self-signed certificate. Set up timestamping. +3. Import [signpath-artifact.xml](signpath-artifact.xml). It requires both + project executables, ProductName `AMSignal`, the identical supplied version, + exact filenames and SHA-256 Authenticode. Do not sign unrelated binaries. +4. Set repository secret `SIGNPATH_API_TOKEN` to a submitter token. Never put + a token/private key in source, release files, command history or chat. +5. Set these repository variables to the values supplied by SignPath: + + | Variable | Value | + |---|---| + | `SIGNPATH_ORGANIZATION_ID` | Organization ID | + | `SIGNPATH_PROJECT_SLUG` | Project slug | + | `SIGNPATH_POLICY_SLUG` | Manually approved production signing policy | + | `SIGNPATH_CONFIGURATION_SLUG` | Imported artifact configuration slug | + | `SIGNPATH_ENABLED` | `true`, only after the settings above are complete | + +6. Commit/tag the release source on `main`. The tag must contain this + integration; historical tags predate executable metadata and cannot use it. + From Actions on **main**, dispatch **Signed release (SignPath Foundation)** + with that existing tag. It builds and tests on GitHub-hosted Windows runners, + uploads the unsigned GUI/CLI artifact, then requests signing. +7. Review the source commit, version and build origin in SignPath and manually + approve the request. The workflow waits up to an hour, then checks trust, + expected publisher and timestamp on both executables before packaging. +8. The workflow creates a **draft** GitHub release with signed GUI/CLI, portable + ZIP and SHA-256 checksums of the final signed bytes. Complete/review the release + notes and publish it. An existing release is never overwritten. + +No paid subscription is configured. Public GitHub-hosted runners are used; +do not move this workflow to private/paid runners without reconsidering cost +and provider origin requirements. SignPath approval remains a real prerequisite. + +## Local build and verification + +Normal `build_cli.ps1`, `build_gui.ps1` and Windows GNU Make builds embed matching +Windows version resources. A local build has **no trusted signature** by default. +The numerical code is unchanged by this integration. + +```powershell +./verify_release_binaries.ps1 -InputDirectory signed -Version 0.17.0-rc.4 -RequireSignature +./package_release.ps1 -InputDirectory signed -Version 0.17.0-rc.4 -OutputDir dist/signed/package -RequireSignature -Zip +``` + +Verification fails for missing/untrusted/untimestamped signatures, an unexpected +publisher, mismatched versions or different GUI/CLI signing certificates. The +unsigned development packaging path is retained and must not be advertised +as a signed release. Do not modify executables after signing. + +Signing helps publisher identity and reputation; it does **not** promise immediate +removal of every Edge/SmartScreen warning. See +[Microsoft's explanation](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation). + +Provider references: [free-program conditions](https://signpath.org/terms.html), +[GitHub integration](https://docs.signpath.io/trusted-build-systems/github), +[artifact metadata restrictions](https://docs.signpath.io/artifact-configuration/reference#file-metadata-restrictions). diff --git a/docs/SIGNPATH_APPLICATION.md b/docs/SIGNPATH_APPLICATION.md new file mode 100644 index 0000000..3829e18 --- /dev/null +++ b/docs/SIGNPATH_APPLICATION.md @@ -0,0 +1,47 @@ +# SignPath Foundation application draft + +**Draft only — not submitted.** Confirm the owner's contact and service terms +before submitting. Certificate access is not yet granted. Do not claim approval. + +| Field | Proposed value | +|---|---| +| Project | AMSignal | +| Repository/homepage | https://github.com/almuleev/AMSignal | +| License | GNU GPLv3; https://github.com/almuleev/AMSignal/blob/main/LICENSE | +| Download page | https://github.com/almuleev/AMSignal/releases | +| Maintainer/approver | almuleev — https://github.com/almuleev | +| Contact email | Confirm with the owner; existing public security contact is `al.muleev@gmail.com` | +| Code signing policy | https://github.com/almuleev/AMSignal/blob/main/docs/CODE_SIGNING.md — must be published first | +| Build workflow | `.github/workflows/release-signpath.yml`, GitHub-hosted Windows/UCRT64 | +| Artifacts | Native GUI `AMSignal--x64.exe` and CLI `lvm_reader.exe` | + +## Description for the application + +AMSignal is a maintained, publicly released GPLv3 native Windows application for +engineering measurement data. It reads LabVIEW LVM, TXT/CSV and AMSignal project +files and provides time-domain plots, FFT spectra and H1/Welch or Direct frequency +response analysis with coherence. It supports formulas, digital filters, +annotations, PNG/data exports and adaptive RAM/disk analysis of large recordings. +GUI and CLI share the project's C++17 parsing/analysis core. + +We request the free SignPath Foundation open-source signing service for both +project executables, distributed as standalone EXE files and a portable ZIP on +GitHub Releases. Builds will originate from reviewed release tags on main using +GitHub-hosted runners; each signing request will require manual maintainer +approval. Windows product metadata and restrictive artifact configuration are +provided. No measurement data is submitted to the signing service. + +The project uses the system Win32/GDI libraries and the MinGW/GCC C++ runtime. +There is no paid proprietary application component in the documented build. +Please review the repository and advise on enrollment requirements and eligibility. + +## Owner confirmations needed + +- Ownership/control of the GitHub repository and the contact address. +- MFA enabled on GitHub and SignPath; approver identity and review responsibility. +- Acceptance of the free-program conditions, including SignPath Foundation being + displayed as signing publisher and manual production approval. +- Installing/authorizing the provider's GitHub App for this repository. + +Apply: https://signpath.org/apply.html. This file does not send an application, +create an account, accept terms, configure secrets or claim a certificate. diff --git a/docs/signpath-artifact.xml b/docs/signpath-artifact.xml new file mode 100644 index 0000000..10178e5 --- /dev/null +++ b/docs/signpath-artifact.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/package_release.ps1 b/package_release.ps1 index 07143de..ab50a23 100644 --- a/package_release.ps1 +++ b/package_release.ps1 @@ -1,6 +1,9 @@ param( [string]$OutputDir = "dist\AMSignal", - [switch]$Zip + [switch]$Zip, + [string]$InputDirectory = $PSScriptRoot, + [string]$Version, + [switch]$RequireSignature ) $ErrorActionPreference = "Stop" @@ -25,9 +28,14 @@ while ($ancestor.Length -ge $releaseRoot.Length) { } $OutputDir = $releaseTarget -$version = git -c core.excludesFile= describe --tags --always --dirty 2>$null -if (-not $version) { $version = "dev" } -$releaseVersion = $version -replace '^v', '' +if ($Version) { $releaseVersion = $Version -replace '^v', '' } +else { + $checkoutVersion = git -c core.excludesFile= describe --tags --always --dirty 2>$null + if (-not $checkoutVersion) { $checkoutVersion = 'dev' } + $releaseVersion = $checkoutVersion -replace '^v', '' +} +$InputDirectory = [IO.Path]::GetFullPath($InputDirectory) +& (Join-Path $PSScriptRoot 'verify_release_binaries.ps1') -InputDirectory $InputDirectory -Version $releaseVersion -RequireSignature:$RequireSignature $guiExe = "AMSignal-$releaseVersion-x64.exe" $zipName = "AMSignal-$releaseVersion-x64.zip" @@ -40,27 +48,46 @@ $files = @( "README.md", "README_EN.md", "README_RU.md", - "LICENSE" + "LICENSE", + "docs\CODE_SIGNING.md" ) foreach ($file in $files) { - if (-not (Test-Path -LiteralPath $file)) { + $sourceRoot = if ($file.EndsWith('.exe')) { $InputDirectory } else { $PSScriptRoot } + if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot $file) -PathType Leaf)) { throw "Required file not found: $file" } } +if ($Zip) { + $zipPath = Join-Path (Split-Path $OutputDir -Parent) $zipName + $checksumPath = [IO.Path]::ChangeExtension($zipPath, '.sha256') + if ((Test-Path -LiteralPath $zipPath) -or (Test-Path -LiteralPath $checksumPath)) { + throw 'Release archive or checksum already exists; choose a new parent directory.' + } +} New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null foreach ($file in $files) { - Copy-Item -LiteralPath $file -Destination $OutputDir -Force + $sourceRoot = if ($file.EndsWith('.exe')) { $InputDirectory } else { $PSScriptRoot } + $destination = Join-Path $OutputDir $file + New-Item -ItemType Directory -Force -Path (Split-Path $destination -Parent) | Out-Null + Copy-Item -LiteralPath (Join-Path $sourceRoot $file) -Destination $destination } +$encoding = New-Object Text.UTF8Encoding($false) +$lines = @(Get-ChildItem -LiteralPath $OutputDir -Recurse -File | Sort-Object FullName | ForEach-Object { + (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + ' ' + + $_.FullName.Substring($OutputDir.Length + 1).Replace('\', '/') +}) +[IO.File]::WriteAllLines((Join-Path $OutputDir 'SHA256SUMS'), $lines, $encoding) if ($Zip) { - $zipPath = Join-Path (Split-Path $OutputDir -Parent) $zipName - if (Test-Path -LiteralPath $zipPath) { - throw 'Release archive already exists; move it or choose a new parent directory.' - } Compress-Archive -Path (Join-Path $OutputDir '*') -DestinationPath $zipPath -CompressionLevel Optimal + $assetLines = @($guiExe, 'lvm_reader.exe') | ForEach-Object { + (Get-FileHash -LiteralPath (Join-Path $OutputDir $_) -Algorithm SHA256).Hash.ToLowerInvariant() + ' ' + $_ + } + $assetLines += (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + ' ' + $zipName + [IO.File]::WriteAllLines($checksumPath, [string[]]$assetLines, $encoding) Write-Host "Created $zipPath" } else { Write-Host "Prepared $OutputDir" diff --git a/tests/release_signing.ps1 b/tests/release_signing.ps1 new file mode 100644 index 0000000..21ada4a --- /dev/null +++ b/tests/release_signing.ps1 @@ -0,0 +1,46 @@ +param([Parameter(Mandatory=$true)][string]$InputDirectory, + [Parameter(Mandatory=$true)][string]$Version) +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +Set-Location $root +$InputDirectory = [IO.Path]::GetFullPath($InputDirectory) +$caseRoot = 'dist/signing-tests-' + [Guid]::NewGuid().ToString('N') +$checks = 0 +function Check([bool]$ok, [string]$message) { + $script:checks++ + if (-not $ok) { throw $message } +} +function Reject([scriptblock]$action, [string]$message) { + $failed = $false + try { & $action } catch { $failed = $true } + Check $failed $message +} +./verify_release_binaries.ps1 -InputDirectory $InputDirectory -Version $Version +Check $true 'production metadata is consistent' +Reject { ./verify_release_binaries.ps1 -InputDirectory $InputDirectory -Version $Version -RequireSignature } 'unsigned binaries must not pass signed-release verification' +Reject { ./verify_release_binaries.ps1 -InputDirectory $InputDirectory -Version '999.0.0' } 'wrong version must be rejected' +Reject { ./package_release.ps1 -InputDirectory $InputDirectory -Version '../escape' -OutputDir "$caseRoot/invalid" } 'unsafe version must be rejected' +Reject { ./package_release.ps1 -InputDirectory $InputDirectory -Version $Version -OutputDir "$caseRoot/signed" -RequireSignature } 'signed packaging must reject unsigned binaries' +Check (-not (Test-Path "$caseRoot/signed")) 'rejected signed packaging must not publish a directory' +./package_release.ps1 -InputDirectory $InputDirectory -Version $Version -OutputDir "$caseRoot/portable" -Zip +$package = [IO.Path]::GetFullPath("$caseRoot/portable") +Check (Test-Path "$package/docs/CODE_SIGNING.md") 'portable policy is included at its README link' +$entries = Get-Content -LiteralPath "$package/SHA256SUMS" +Check ($entries.Count -eq 10) 'all packaged source files have hashes' +foreach ($entry in $entries) { + $hash, $name = $entry -split ' ', 2 + Check ((Get-FileHash -LiteralPath (Join-Path $package $name)).Hash.ToLowerInvariant() -ceq $hash) 'checksum must match final copied bytes' +} +$zip = [IO.Path]::GetFullPath("$caseRoot/AMSignal-$Version-x64.zip") +$before = (Get-FileHash -LiteralPath $zip).Hash +Reject { ./package_release.ps1 -InputDirectory $InputDirectory -Version $Version -OutputDir "$caseRoot/other" -Zip } 'existing ZIP must not be overwritten' +Check ((Get-FileHash -LiteralPath $zip).Hash -ceq $before -and -not (Test-Path "$caseRoot/other")) 'collision preserves existing ZIP and avoids partial output' +Reject { ./package_release.ps1 -InputDirectory $InputDirectory -Version $Version -OutputDir "$caseRoot/portable" } 'existing output directory must be preserved' +Reject { ./package_release.ps1 -InputDirectory $InputDirectory -Version $Version -OutputDir '../outside-signing-test' } 'output must remain inside dist' +$archiveRoot = Join-Path $caseRoot 'extracted' +Expand-Archive -LiteralPath $zip -DestinationPath $archiveRoot +Check ((Get-FileHash "$archiveRoot/lvm_reader.exe").Hash -ceq (Get-FileHash "$package/lvm_reader.exe").Hash) 'ZIP preserves executable bytes' +Check (Test-Path "$archiveRoot/docs/CODE_SIGNING.md") 'ZIP preserves the policy path' +$assets = Get-Content -LiteralPath ([IO.Path]::ChangeExtension($zip, '.sha256')) +Check ($assets.Count -eq 3 -and $assets[2].StartsWith($before.ToLowerInvariant() + ' ')) 'release checksum includes the final ZIP' +Write-Host "$checks release signing/package checks passed. Outputs retained at $caseRoot" diff --git a/verify_release_binaries.ps1 b/verify_release_binaries.ps1 new file mode 100644 index 0000000..501bd15 --- /dev/null +++ b/verify_release_binaries.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory=$true)][string]$InputDirectory, + [Parameter(Mandatory=$true)][string]$Version, + [switch]$RequireSignature +) +$ErrorActionPreference = 'Stop' +if ($Version -notmatch '^[A-Za-z0-9][A-Za-z0-9.+-]{0,127}$') { throw 'Invalid release version.' } +$expected = @("AMSignal-$Version-x64.exe", 'lvm_reader.exe') +$publisher = $null +foreach ($name in $expected) { + $path = Join-Path $InputDirectory $name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required executable not found: $name" } + $item = Get-Item -LiteralPath $path + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Executable must not be a directory link.' } + $info = $item.VersionInfo + if ($info.ProductName -cne 'AMSignal' -or $info.ProductVersion -cne $Version -or + $info.FileVersion -cne $Version -or $info.OriginalFilename -cne $name) { + throw "Executable metadata does not match release $Version`: $name" + } + if ($RequireSignature) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne 'Valid' -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) { + throw "Trusted, timestamped Authenticode signature is required: $name ($($signature.Status))" + } + if ($signature.SignerCertificate.Subject -notmatch '(?:^|,\s*)CN=SignPath Foundation(?:,|$)') { + throw "Unexpected signing publisher: $name" + } + $thumbprint = $signature.SignerCertificate.Thumbprint + if ($publisher -and $publisher -cne $thumbprint) { throw 'GUI and CLI must have the same signing certificate.' } + $publisher = $thumbprint + } +} +Write-Host "Verified release metadata$(if ($RequireSignature) { ', signatures and timestamps' }): $Version"