diff --git a/.github/release/licenses/Avalonia-MIT.txt b/.github/release/licenses/Avalonia-MIT.txt
new file mode 100644
index 0000000..4195180
--- /dev/null
+++ b/.github/release/licenses/Avalonia-MIT.txt
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) AvaloniaUI OÜ
+All Rights Reserved
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/.github/release/licenses/DejaVu-font-notices.txt b/.github/release/licenses/DejaVu-font-notices.txt
new file mode 100644
index 0000000..c96cb7f
--- /dev/null
+++ b/.github/release/licenses/DejaVu-font-notices.txt
@@ -0,0 +1,101 @@
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
+Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
+DejaVu changes are in public domain
+
+
+Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. Glyphs imported from Arev fonts are (c) Tavmjung Bah (see below)
+
+Bitstream Vera Fonts Copyright
+------------------------------
+
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
+a trademark of Bitstream, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of the fonts accompanying this license ("Fonts") and associated
+documentation files (the "Font Software"), to reproduce and distribute the
+Font Software, including without limitation the rights to use, copy, merge,
+publish, distribute, and/or sell copies of the Font Software, and to permit
+persons to whom the Font Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright and trademark notices and this permission notice shall
+be included in all copies of one or more of the Font Software typefaces.
+
+The Font Software may be modified, altered, or added to, and in particular
+the designs of glyphs or characters in the Fonts may be modified and
+additional glyphs or characters may be added to the Fonts, only if the fonts
+are renamed to names not containing either the words "Bitstream" or the word
+"Vera".
+
+This License becomes null and void to the extent applicable to Fonts or Font
+Software that has been modified and is distributed under the "Bitstream
+Vera" names.
+
+The Font Software may be sold as part of a larger software package but no
+copy of one or more of the Font Software typefaces may be sold by itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
+TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
+FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
+ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
+THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
+FONT SOFTWARE.
+
+Except as contained in this notice, the names of Gnome, the Gnome
+Foundation, and Bitstream Inc., shall not be used in advertising or
+otherwise to promote the sale, use or other dealings in this Font Software
+without prior written authorization from the Gnome Foundation or Bitstream
+Inc., respectively. For further information, contact: fonts at gnome dot
+org.
+
+Arev Fonts Copyright
+------------------------------
+
+Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the fonts accompanying this license ("Fonts") and
+associated documentation files (the "Font Software"), to reproduce
+and distribute the modifications to the Bitstream Vera Font Software,
+including without limitation the rights to use, copy, merge, publish,
+distribute, and/or sell copies of the Font Software, and to permit
+persons to whom the Font Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright and trademark notices and this permission notice
+shall be included in all copies of one or more of the Font Software
+typefaces.
+
+The Font Software may be modified, altered, or added to, and in
+particular the designs of glyphs or characters in the Fonts may be
+modified and additional glyphs or characters may be added to the
+Fonts, only if the fonts are renamed to names not containing either
+the words "Tavmjong Bah" or the word "Arev".
+
+This License becomes null and void to the extent applicable to Fonts
+or Font Software that has been modified and is distributed under the
+"Tavmjong Bah Arev" names.
+
+The Font Software may be sold as part of a larger software package but
+no copy of one or more of the Font Software typefaces may be sold by
+itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
+TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+Except as contained in this notice, the name of Tavmjong Bah shall not
+be used in advertising or otherwise to promote the sale, use or other
+dealings in this Font Software without prior written authorization
+from Tavmjong Bah. For further information, contact: tavmjong @ free
+. fr.
\ No newline at end of file
diff --git a/.github/release/licenses/Inter-OFL.txt b/.github/release/licenses/Inter-OFL.txt
new file mode 100644
index 0000000..ff80f8c
--- /dev/null
+++ b/.github/release/licenses/Inter-OFL.txt
@@ -0,0 +1,94 @@
+Copyright (c) 2016-2020 The Inter Project Authors.
+"Inter" is trademark of Rasmus Andersson.
+https://github.com/rsms/inter
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/.github/release/licenses/PDFsharp-MIT.txt b/.github/release/licenses/PDFsharp-MIT.txt
new file mode 100644
index 0000000..03b0a37
--- /dev/null
+++ b/.github/release/licenses/PDFsharp-MIT.txt
@@ -0,0 +1,23 @@
+Copyright (c) 2001-2026 empira Software GmbH, Troisdorf (Cologne Area), Germany
+
+http://docs.pdfsharp.net
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
diff --git a/.github/release/package.ps1 b/.github/release/package.ps1
new file mode 100644
index 0000000..10d5ca2
--- /dev/null
+++ b/.github/release/package.ps1
@@ -0,0 +1,141 @@
+#requires -Version 7
+<#
+ Архив релиза SignService для Windows x64:
+ SignService-<версия>-win-x64.zip — SignService.exe, README.txt, CHANGELOG.md,
+ LICENSE.txt, licenses/, SHA256SUMS.txt;
+ SignService-<версия>-win-x64.sha256 — SHA-256 архива и exe;
+ RELEASE_NOTES.md — раздел версии из CHANGELOG.md для описания релиза.
+ Лицензии пакетов NuGet берутся из графа восстановления после dotnet publish
+ (включая пакет среды выполнения), тексты без файла в пакете — из licenses/ рядом.
+ Запуск: pwsh .github/release/package.ps1 -PublishDir publish -OutputDir dist [-Tag v1.2.0]
+#>
+param(
+ [Parameter(Mandatory)] [string] $PublishDir,
+ [Parameter(Mandatory)] [string] $OutputDir,
+ # Тег релиза: должен совпадать с версией проекта (иначе автообновление зациклится).
+ [string] $Tag = ''
+)
+
+$ErrorActionPreference = 'Stop'
+$root = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path
+$project = Join-Path $root 'src' 'SignService' 'SignService.csproj'
+$version = ([xml](Get-Content $project -Raw)).Project.PropertyGroup.Version |
+ Where-Object { $_ } | Select-Object -First 1
+if (-not $version) { throw "В $project не указана версия." }
+if ($Tag -and $Tag -ne "v$version") { throw "Тег $Tag не совпадает с версией проекта $version." }
+
+$exe = Join-Path $PublishDir 'SignService.exe'
+if (-not (Test-Path $exe)) { throw "Не найден $exe — сначала выполните dotnet publish." }
+
+$name = "SignService-$version-win-x64"
+New-Item -ItemType Directory -Force $OutputDir | Out-Null
+$OutputDir = (Resolve-Path $OutputDir).Path
+$stage = Join-Path $OutputDir $name
+if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
+$licenses = Join-Path $stage 'licenses'
+New-Item -ItemType Directory -Force $licenses | Out-Null
+
+function Write-Text([string] $path, [string[]] $lines) {
+ # UTF-8 с BOM и CRLF — корректно открывается Блокнотом любой версии Windows.
+ [IO.File]::WriteAllText($path, ($lines -join "`r`n") + "`r`n", [Text.UTF8Encoding]::new($true))
+}
+
+function Get-Sha256([string] $path) { (Get-FileHash -Algorithm SHA256 $path).Hash.ToLowerInvariant() }
+
+Copy-Item $exe $stage
+Copy-Item (Join-Path $root 'CHANGELOG.md') $stage
+Copy-Item (Join-Path $root 'LICENSE') (Join-Path $stage 'LICENSE.txt')
+Copy-Item (Join-Path $PSScriptRoot 'licenses' '*') $licenses
+
+# Лицензии пакетов NuGet из графа восстановления: библиотеки и загруженные
+# пакеты (среда выполнения win-x64 для самодостаточного exe).
+$assets = Get-Content (Join-Path $root 'src' 'SignService' 'obj' 'project.assets.json') -Raw | ConvertFrom-Json
+$packagesRoot = $assets.project.restore.packagesPath
+$packages = [Collections.Generic.List[object]]::new()
+foreach ($library in $assets.libraries.PSObject.Properties) {
+ if ($library.Value.type -ne 'package') { continue }
+ $id, $packageVersion = $library.Name -split '/', 2
+ $packages.Add([pscustomobject]@{ Id = $id.ToLowerInvariant(); Version = $packageVersion; Path = $library.Value.path })
+}
+foreach ($framework in $assets.project.frameworks.PSObject.Properties) {
+ foreach ($download in @($framework.Value.downloadDependencies)) {
+ if (-not $download) { continue }
+ $id = $download.name.ToLowerInvariant()
+ $packageVersion = $download.version.Trim('[', ']', ' ').Split(',')[0].Trim()
+ $packages.Add([pscustomobject]@{ Id = $id; Version = $packageVersion; Path = "$id/$packageVersion" })
+ }
+}
+
+$copied = 0
+foreach ($package in $packages | Sort-Object Id, Version -Unique) {
+ $directory = Join-Path $packagesRoot $package.Path
+ if (-not (Test-Path $directory)) { Write-Warning "Нет каталога пакета $directory"; continue }
+ Get-ChildItem $directory -File |
+ Where-Object { $_.Name -match '^(licen[cs]e|copying|notice|third-?party-?notices|thirdpartynotices)' } |
+ ForEach-Object {
+ Copy-Item $_.FullName (Join-Path $licenses "$($package.Id)-$($package.Version)-$($_.Name)")
+ $copied++
+ }
+}
+if ($copied -eq 0) { throw 'Не найдено ни одной лицензии пакетов NuGet — проверьте восстановление.' }
+
+$repository = 'https://github.com/zaynullinmi/SignService'
+Write-Text (Join-Path $stage 'README.txt') @(
+ "SignService $version — Windows x64",
+ '',
+ 'Запустите SignService.exe. Установка .NET не требуется.',
+ 'Подписание: перетащите файлы в окно, выберите сертификат и нажмите «Подписать».',
+ 'Проверка ЭЦП: «Инструменты → Проверить ЭЦП…» или ПКМ по файлу → «Подписанты и проверка ЭЦП…».',
+ 'Для откреплённой подписи нужен исходный документ.',
+ 'Проверка ГОСТ и операции с готовыми подписями работают без КриптоПро.',
+ 'Для создания новой ГОСТ-подписи требуется КриптоПро CSP и закрытый ключ.',
+ 'Результаты доверия, отзыва сертификатов и TSA указаны в отчёте проверки отдельно.',
+ '',
+ "Исходный код версии: $repository/tree/v$version",
+ "Релиз: $repository/releases/tag/v$version",
+ 'Лицензии используемых компонентов находятся в каталоге licenses.'
+)
+# Формат sha256sum (без BOM): проверка — sha256sum -c SHA256SUMS.txt.
+[IO.File]::WriteAllText((Join-Path $stage 'SHA256SUMS.txt'), "$(Get-Sha256 $exe) SignService.exe`n",
+ [Text.UTF8Encoding]::new($false))
+
+$zip = Join-Path $OutputDir "$name.zip"
+if (Test-Path $zip) { Remove-Item $zip -Force }
+Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip -CompressionLevel Optimal
+$zipHash = Get-Sha256 $zip
+$exeHash = Get-Sha256 $exe
+[IO.File]::WriteAllText((Join-Path $OutputDir "$name.sha256"),
+ "$zipHash $name.zip`n$exeHash SignService.exe`n", [Text.UTF8Encoding]::new($false))
+
+# Описание релиза — раздел этой версии из истории изменений.
+$changelog = Get-Content (Join-Path $root 'CHANGELOG.md')
+$start = [Array]::FindIndex([string[]] $changelog, [Predicate[string]] { param($line) $line -like "## v$version*" })
+if ($start -lt 0) {
+ if ($Tag) { throw "В CHANGELOG.md нет раздела «## v$version»." }
+ $section = @("## v$version")
+}
+else {
+ $end = [Array]::FindIndex([string[]] $changelog, $start + 1, [Predicate[string]] { param($line) $line -like '## *' })
+ $last = if ($end -lt 0) { $changelog.Count - 1 } else { $end - 1 }
+ while ($last -gt $start -and -not $changelog[$last].Trim()) { $last-- }
+ $section = $changelog[$start..$last]
+}
+$notes = @($section) + @(
+ '',
+ '**Файлы**',
+ '',
+ '- `SignService.exe` — программа (Windows x64, установка .NET не требуется); этот файл скачивает автообновление;',
+ "- ``$name.zip`` — программа с README, историей версий и лицензиями компонентов;",
+ "- ``$name.sha256`` — контрольные суммы SHA-256.",
+ '',
+ '```',
+ "$zipHash $name.zip",
+ "$exeHash SignService.exe",
+ '```'
+)
+[IO.File]::WriteAllText((Join-Path $OutputDir 'RELEASE_NOTES.md'), ($notes -join "`n") + "`n", [Text.UTF8Encoding]::new($false))
+
+Write-Host "Архив: $zip"
+Write-Host "Лицензий пакетов NuGet: $copied"
+Write-Host "SHA-256: $zipHash $name.zip"
+Write-Host "SHA-256: $exeHash SignService.exe"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8f660c4..7ce3015 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,6 +4,7 @@ on:
push:
branches: ["main", "claude/**"]
tags: ["v*"]
+ pull_request:
workflow_dispatch:
inputs:
release_tag:
@@ -12,7 +13,7 @@ on:
default: ""
permissions:
- contents: write # публикация GitHub Release на тег v*
+ contents: read
jobs:
tests:
@@ -30,7 +31,10 @@ jobs:
build-windows:
name: Windows x64 exe
+ needs: tests
runs-on: windows-latest
+ permissions:
+ contents: write # publication on a version tag or an explicitly requested release
steps:
- uses: actions/checkout@v4
@@ -38,6 +42,10 @@ jobs:
with:
dotnet-version: 8.0.x
+ # Windows-ветки: CryptoAPI-подпись, хранилище сертификатов, помощник автообновления.
+ - name: Run Windows test suite (CryptoAPI and update rollback)
+ run: dotnet run --project tests/SignService.Tests -c Release
+
- name: Publish self-contained single-file exe
run: >
dotnet publish src/SignService -c Release -r win-x64
@@ -47,18 +55,34 @@ jobs:
-p:EnableCompressionInSingleFile=true
-o publish
+ # Архив релиза: exe, README.txt, CHANGELOG, LICENSE, лицензии компонентов,
+ # SHA-256 и описание релиза из CHANGELOG. Тег релиза обязан совпадать
+ # с версией проекта — иначе автообновление предлагало бы его бесконечно.
+ - name: Package release archive
+ shell: pwsh
+ env:
+ RELEASE_TAG: ${{ inputs.release_tag || (startsWith(github.ref, 'refs/tags/v') && github.ref_name || '') }}
+ run: ./.github/release/package.ps1 -PublishDir publish -OutputDir dist -Tag "$env:RELEASE_TAG"
+
- uses: actions/upload-artifact@v4
with:
name: SignService-win-x64
- path: publish/SignService.exe
+ path: |
+ dist/SignService-*-win-x64/
+ dist/SignService-*-win-x64.sha256
if-no-files-found: error
# Публикация GitHub Release: на тег v* либо по ручному запуску
# с указанным release_tag (тег создаётся на текущем коммите).
+ # SignService.exe — отдельным файлом: его скачивает автообновление.
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.release_tag || github.ref_name }}
- files: publish/SignService.exe
- body_path: CHANGELOG.md
+ target_commitish: ${{ github.sha }}
+ files: |
+ publish/SignService.exe
+ dist/SignService-*-win-x64.zip
+ dist/SignService-*-win-x64.sha256
+ body_path: dist/RELEASE_NOTES.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18a2b91..a6415bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,60 @@
# История версий SignService
-## v1.1.0 — не выпущена
+## v1.2.0 — 07.10.2026
+
+- **Переработан интерфейс**: лог операций всегда виден в главном окне
+ (отдельная статусная строка убрана); штамп времени (CAdES-T, адрес TSA)
+ настраивается в окне «Настройки…».
+- **Контекстное меню файла (ПКМ)**: подписанты и проверка ЭЦП (окно проверки
+ открывается сразу с «имя.sig» и документом), приложение подписей других лиц,
+ штамп, извлечение, сборка контейнера, разделение подписи, исключение
+ подписанта, открытие папки.
+- **Разделение групповой подписи** на отдельные .sig по подписантам
+ и **исключение подписанта** из .sig (создаётся новый файл, исходный
+ не меняется; последнего подписанта исключить нельзя) — в меню
+ «Инструменты» и в контекстном меню файла.
+- **Диалог параметров штампа** перед операцией: страницы (первая, последняя,
+ все, указанные «1,3-5»), дата, логотип, режим подписи копии; на каждой
+ выбранной странице — отдельная плашка на каждого подписанта.
+- Приложенная подпись другого лица или смена сертификата возвращают уже
+ подписанный файл в очередь — для соподписания.
+- **МЧД**: сертификат руководителя, истёкший уже после выдачи доверенности,
+ больше не отклоняет её — выводится предупреждение (истёкший до выдачи
+ или ещё не действовавший — ошибка); представитель сопоставляется по СНИЛС,
+ если в его сертификате указан только ИНН организации; проверка идёт в фоне
+ и выполняется один раз на пакет файлов.
+- **Объединение подписей**: подпись, которую не удалось проверить из-за
+ ограничений библиотеки, больше не исключается — она сохраняется и
+ указывается как непроверенная; документ хешируется один раз на алгоритм,
+ поэтому объединение больших файлов быстрее.
+- Недоступный файл доверенного корня больше не прерывает проверку и не
+ сбрасывает сохранённую МЧД — он пропускается с записью в лог; учитывается
+ настройка сетевых запросов CRL/OCSP.
+- **Автообновление надёжнее**: прежний exe сохраняется резервной копией,
+ замена занятого файла повторяется, при неудачном запуске новой версии
+ восстанавливается прежняя.
+- Релиз собирается автоматически: архив с README, историей версий
+ и лицензиями компонентов, контрольные суммы SHA-256; тесты выполняются
+ на Linux и Windows, включая проверку интерфейса без экрана.
+
+## v1.1.0 — 02.10.2026
+
+- **Проверка ЭЦП без КриптоПро**: пункт «Инструменты → Проверить ЭЦП…»,
+ CMS/CAdES с ГОСТ-2001, ГОСТ-2012 256/512, RSA и ECDSA; отчёт по каждому
+ подписанту с экспортом в TXT и отменой проверки.
+- Раздельные результаты для документа, значения подписи, привязки сертификата,
+ доверия, отзыва и TSA. Цепочки сертификатов проверяются без криптопровайдера;
+ вложенный корень автоматически не считается доверенным. Поддерживаются
+ подписанные CRL/OCSP и сетевые запросы по выбору; отсутствие подтверждений
+ отображается явно. TSA проверяется вместе с привязкой к подписи и запросу.
+- Исправлены проверки объединения и МЧД: совпадение хеша больше не пропускает
+ повреждённое значение подписи; исключённый подписант не оставляет старый
+ вложенный документ, выбранный режим откреплённой подписи сохраняется.
+- МЧД проверяется по текущим XML/SIG и копируется из проверенного снимка;
+ конфликт имён файлов и противоречивые ИНН/СНИЛС останавливают операцию.
+ Файлы подписи сохраняются через временный файл и атомарную замену.
+- Ограничена вложенность ASN.1 и исправлен контроль переполнения длины.
+ Тесты запускаются для pull request; сборка Windows зависит от их успеха.
- **Штамп на PDF безопасен при соподписании**: по умолчанию подписывается
ОРИГИНАЛ документа, а копия «имя (со штампом).pdf» создаётся отдельно
@@ -8,8 +62,8 @@
(по блоку данных на каждого). Прежний режим «подписывать копию со
штампом» остался отдельной галочкой с предупреждением.
- **Доверенность МЧД** (кнопка «Добавить доверенность…», как в Контуре):
- загрузка XML (EMCHD_1) и подписи руководителя, проверка подписи по хешу
- (для ГОСТ — Стрибогом), срока действия и соответствия представителя
+ загрузка XML (EMCHD_1) и подписи руководителя, криптографическая проверка,
+ проверка срока действия и соответствия представителя
сертификату по ИНН/СНИЛС; при подписании файлы МЧД копируются рядом
с документом, номер МЧД добавляется в штамп на PDF.
diff --git a/README.en.md b/README.en.md
index 8dc1e5d..6badad7 100644
--- a/README.en.md
+++ b/README.en.md
@@ -43,19 +43,38 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
a signature file onto the window;
- re-signing with the same certificate **replaces** your previous signature
instead of duplicating it;
-- every merged signature is **verified against the document hash**
- (messageDigest; Streebog for GOST): signatures made over a different file
- or an older revision are excluded automatically, reporting the signer name;
+- every merged signature is **cryptographically verified**, including the
+ document digest, signature value and CAdES certificate binding. Damaged
+ signatures and signatures over another revision are excluded; signers that
+ cannot be verified (unknown algorithm, missing certificate) are kept and
+ explicitly reported;
+- attaching another person's signature or switching the certificate puts an
+ already signed file back into the queue so it can be co-signed;
- input signatures are accepted in DER/BER (including indefinite lengths and
trailing bytes) and base64/PEM.
### Tools (no signature created)
+The operations are available in the **Tools** menu (pick files) and in the
+**context menu of a file** in the list (right click) — then the neighbouring
+`name.sig` is used.
+
+- **Verify signature…** — per-signer CMS/CAdES verification for GOST-2001,
+ GOST-2012 256/512, RSA and ECDSA without CryptoPro. Select the original for
+ a detached signature; attached signatures use the embedded document. From
+ the context menu ("Signers and verification…") verification starts at once.
+ Copy or export the detailed report to TXT;
- **Merge .sig…** — combine several signature files into one containing all
signers (attached containers are accepted; the embedded document is kept);
- **Extract from .sig…** — pull out of a container: the embedded document
(byte-exact), a detached signature with all signers, and individual `.sig`
files per signer (signer names in the file names);
+- **Split a group signature…** — a separate detached `.sig` per signer (the
+ embedded document is saved next to them);
+- **Remove a signer from .sig…** — pick the signer in a list (identical names
+ are told apart by the certificate serial number); a new file
+ `name (без подписанта).sig` is created, the source is left untouched and the
+ last signer cannot be removed;
- **Build container…** — pack a document together with its existing
signatures into an attached `.sig` (the inverse of extraction);
- **Stamp PDF…** — save a stamped copy of a document without signing.
@@ -65,20 +84,30 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- the "Add power of attorney…" button works like Kontur: pick the MChD XML
(EMCHD_1 format) and the head's signature (.sig; a neighbouring
`name.xml.sig` is picked up automatically);
-- the app verifies: the head's signature matches the MChD file (by hash,
- Streebog for GOST), the validity period has not expired, and the
- representative in the MChD matches the selected certificate by INN/SNILS;
+- the app verifies the head's signature using the certificate public key,
+ checks the MChD validity period and that the representative is the owner of
+ the selected certificate: the personal INN (12 digits) and SNILS are compared
+ when present on both sides; an organization INN in the certificate is never
+ compared with a personal INN;
+- a head certificate that expired after the MChD was issued does not reject
+ it — the certificate is checked as of the issue date and a warning is shown;
+ a certificate that expired before the issue date (or was not yet valid) is
+ an error;
- when signing, the MChD files (XML + .sig) are copied next to the signed
document (the MChD is NOT embedded into the CMS signature — Kontur does
the same), and the visual PDF stamp gains a
"Acting under power of attorney No. …" line;
-- the power of attorney is remembered and re-validated on every signing.
+- the power of attorney is remembered and re-validated in the background at
+ startup and once before every signing batch.
### Visual stamp on PDF
-- a "DOCUMENT SIGNED WITH ELECTRONIC SIGNATURE" box on the last page:
- certificate number, owner, validity period, optionally the signing date and
- an **organization logo** (PNG/JPEG);
+- a "DOCUMENT SIGNED WITH ELECTRONIC SIGNATURE" box: certificate number, owner,
+ validity period, optionally the signing date and an **organization logo**
+ (PNG/JPEG);
+- the parameters are chosen in a dialog before the operation: pages (first,
+ last, all or a list such as "1,3-5"), date, logo; every selected page gets
+ a **separate box per signer**;
- by default the **original** file is signed, and the stamped copy
`name (stamped).pdf` is a separate **unsigned** file recreated after every
signing with **all signers** of the resulting signature (safe for
@@ -103,16 +132,49 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
### Miscellaneous
-- an **operation log window** with timestamps;
+- the **operation log** is always visible in the main window, with timestamps;
+- a **Settings window**: timestamp (CAdES-T) and the TSA URL;
- an **About window**: version, author contacts, changelog, update check;
- **auto-update**: new releases are checked on GitHub Releases at startup
- (can be disabled) and installed in one click from the About window (Windows).
+ (can be disabled) and installed in one click from the About window (Windows);
+ the previous exe is kept as a backup and restored if the new version fails
+ to start.
+
+### Trust, revocation and timestamps
+
+The report separates document integrity, signature value, certificate binding,
+certificate trust, revocation and TSA results. An embedded root never creates
+trust: anchors come from OS root stores and explicitly selected CA certificates.
+BouncyCastle verifies certificate-chain, CRL and OCSP signatures without a CSP.
+
+Verification is offline by default. It uses embedded CRLs and selected `.crl`/
+`.ocsp` files; HTTP(S) CRL/OCSP requests require the network checkbox (the
+setting is remembered and also applies to MChD checks). An unavailable trusted
+root file is skipped with a warning. Revocation
+is checked for every chain certificate except the trust anchor. Missing, stale,
+unverifiable or partial evidence (including delta/indirect CRLs) gives an unknown
+status. Missing intermediate certificates must be embedded in the CMS or in OS
+intermediate stores.
+
+The signer's `signing-time` does not prove when a signature was created.
+Historical certificate validity is assessed only using a TSA timestamp whose
+imprint, signature, trust and required revocation checks passed. TSA responses
+also must match the request and nonce.
+
+MChD verification re-reads current files before a signing batch, verifies and
+copies the same XML/SIG snapshot. If the head certificate expired after the MChD
+was issued and there is no TSA timestamp, the signing time is not proven — the app
+warns and checks the certificate as of the issue date. Conflicting destination files stop signing before the signature is
+saved. Cryptographic verification does not establish the head's authority,
+the scope of delegated powers or MChD registry revocation; missing assurances
+are reported as warnings.
## Requirements
- Windows 10/11 — the self-contained exe from the Releases page;
-- for GOST signatures — **CryptoPro CSP** installed with a personal
+- to **create** GOST signatures — **CryptoPro CSP** installed with a personal
certificate;
+- verifying and merging GOST signatures requires **no CryptoPro** on Windows/Linux/macOS;
- building from source: .NET 8 SDK (Windows/Linux/macOS; outside Windows
signing is limited to platform algorithms — RSA/ECDSA; GOST requires
the CSP on Windows).
@@ -125,6 +187,12 @@ dotnet run --project src/SignService
dotnet run --project tests/SignService.Tests -c Release # tests
```
+On a personal workstation append `-- --skip-user-store` to the test command to
+skip scenarios that change user certificate stores or application data. CI runs
+the full suite in isolated Linux and Windows runners. Managed GOST, CRL/OCSP and
+TSA verification tests run without CryptoPro or OpenSSL; the UI is smoke-tested
+without a display (Avalonia.Headless).
+
Publishing the self-contained exe:
```bash
@@ -138,11 +206,12 @@ dotnet publish src/SignService -c Release -r win-x64 --self-contained true \
## Usage
1. Start the app — certificates with a private key appear in the drop-down.
-2. Pick a certificate and the signature mode; optionally enable the timestamp
- and/or the PDF stamp.
+2. Pick a certificate and the signature mode; optionally enable the PDF stamp
+ (a dialog asks for its parameters); the timestamp is set up in **Settings…**.
3. Drag files into the window (or use the browser) and press **Sign** —
a `name.sig` appears next to each file.
-4. Operations on existing signatures live in the **Tools** menu.
+4. Operations on existing signatures live in the **Tools** menu and in the
+ file context menu (right click); progress is shown in the log.
The change history is in [CHANGELOG.md](CHANGELOG.md) (Russian) and in the
About window.
@@ -166,14 +235,20 @@ src/SignService/
│ ├── CadesAttributes.cs # CAdES-BES attributes: signing-time, signing-cert-v2
│ ├── Streebog.cs # GOST R 34.11-2012 for certHash (RFC 6986 vectors)
│ ├── CmsMerger.cs # ASN.1-level merge/split/build of signatures
-│ ├── CmsExtractor.cs # file operations: extract, merge, container
+│ ├── CmsExtractor.cs # file operations: extract, merge, split, container
+│ ├── SignatureVerifier.cs # per-signer verification (BouncyCastle, GOST without a CSP)
+│ ├── CertificateValidator.cs # trust chain (PKIX) and OS stores
+│ ├── RevocationChecker.cs # certificate revocation: CRL and OCSP
+│ ├── PowerOfAttorneyService.cs # MChD: EMCHD_1 parsing, checks, copying
│ ├── BerDer.cs # BER → definite-length normalization
+│ ├── AtomicFile.cs # file writes through a temporary file
│ ├── CertificateVault.cs # certificate on disk: PFX and Windows store
│ ├── TimestampClient.cs # RFC 3161 TSA client for CAdES-T
-│ ├── PdfStamper.cs # visual PDF stamp (details, date, logo)
+│ ├── PdfStamper.cs # visual PDF stamp (pages, signers, logo)
│ ├── UpdateService.cs # auto-update via GitHub Releases
-│ └── AppSettings.cs # settings (certificate, modes, stamps)
+│ └── AppSettings.cs # settings (certificate, modes, stamps, trust)
├── ViewModels/ # MVVM: main window, files, certificates
-└── Views/ # windows: main, password, confirm, about
-tests/SignService.Tests/ # integration tests (run in CI)
+└── Views/ # windows: main, verification, stamp, signers, settings…
+tests/SignService.Tests/ # integration tests and UI smoke test (CI: Linux and Windows)
+.github/release/ # release archive packaging: script and license texts
```
diff --git a/README.md b/README.md
index 9878f0d..01dd046 100644
--- a/README.md
+++ b/README.md
@@ -37,19 +37,36 @@
с существующей `имя.sig` рядом (отключается), кнопкой «+.sig» у файла
или перетаскиванием файла подписи в окно;
- повторная подпись тем же сертификатом **заменяет** свою старую, а не дублирует;
-- каждая объединяемая подпись **проверяется по хешу документа** (messageDigest;
- для ГОСТ — Стрибогом): подписи под другим файлом или прежней версией
- исключаются автоматически с указанием подписанта;
+- каждая объединяемая подпись **проверяется криптографически**: хеш документа,
+ значение подписи и привязка сертификата CAdES. Повреждённые подписи и подписи
+ под другой версией исключаются с указанием подписанта; подписи, которые проверить
+ не удалось (неизвестный алгоритм, нет сертификата), сохраняются и явно отмечаются;
+- приложенная подпись другого лица или смена сертификата возвращают уже подписанный
+ файл в очередь — его можно соподписать;
- входные подписи принимаются в DER/BER (включая неопределённые длины
и «хвосты» файлов) и base64/PEM.
### Инструменты (без создания подписи)
+Операции доступны в меню **«Инструменты»** (с выбором файлов) и в **контекстном
+меню файла** из списка (ПКМ) — тогда берётся `имя.sig` рядом с документом.
+
+- **Проверить ЭЦП…** — проверка каждого подписанта CMS/CAdES: ГОСТ-2001,
+ ГОСТ-2012 256/512, RSA и ECDSA без КриптоПро. Для откреплённой подписи
+ выберите исходный документ; для прикреплённой он берётся из контейнера.
+ Из контекстного меню («Подписанты и проверка ЭЦП…») проверка запускается
+ сразу. Отчёт можно скопировать или сохранить в TXT;
- **Объединить .sig…** — несколько файлов подписей склеиваются в один со всеми
подписантами (принимаются и прикреплённые контейнеры — документ сохраняется);
- **Извлечь из .sig…** — из криптоконтейнера достаются: вложенный документ
(байт-в-байт), откреплённая подпись со всеми подписантами и отдельные `.sig`
по каждому подписанту (в имени — ФИО из сертификата);
+- **Разделить групповую подпись…** — отдельный откреплённый `.sig` на каждого
+ подписанта (вложенный документ сохраняется рядом);
+- **Исключить подписанта из .sig…** — подписант выбирается в списке (одинаковые
+ ФИО различаются по серийному номеру сертификата); создаётся новый файл
+ «имя (без подписанта).sig», исходный не меняется, последнего подписанта
+ исключить нельзя;
- **Собрать контейнер…** — документ упаковывается в прикреплённый `.sig`
вместе с имеющимися подписями (обратная операция к извлечению);
- **Штамп на PDF…** — копия документа с плашкой о подписании без подписи.
@@ -59,20 +76,27 @@
- кнопка «Добавить доверенность…» — как в Контуре: выбираются файл МЧД
(XML, формат EMCHD_1) и подпись руководителя (.sig; `имя.xml.sig` рядом
подхватывается автоматически);
-- программа проверяет: подпись руководителя соответствует файлу МЧД
- (по хешу, для ГОСТ — Стрибогом), срок действия не истёк, представитель
- в МЧД совпадает с владельцем выбранного сертификата по ИНН/СНИЛС;
+- программа проверяет подпись руководителя открытым ключом сертификата,
+ срок действия МЧД и то, что представитель в МЧД — владелец выбранного
+ сертификата: личный ИНН (12 цифр) и СНИЛС сверяются, если указаны с обеих
+ сторон; ИНН организации из сертификата с ИНН физлица не сравнивается;
+- сертификат руководителя, истёкший уже после выдачи МЧД, не отклоняет её —
+ программа проверяет его на дату выдачи и предупреждает; истёкший до выдачи
+ (или ещё не действовавший) — ошибка;
- при подписании файлы МЧД (XML + .sig) копируются рядом с подписанным
документом (в CMS-подпись МЧД не вкладывается — так же поступает Контур),
а в визуальный штамп на PDF добавляется строка
«Действует на основании МЧД № …»;
-- доверенность запоминается и перепроверяется при каждом подписании.
+- доверенность запоминается и перепроверяется в фоне при запуске и перед
+ каждым пакетным подписанием (один раз на пакет).
### Визуальный штамп на PDF
-- плашка «ДОКУМЕНТ ПОДПИСАН ЭЛЕКТРОННОЙ ПОДПИСЬЮ» на последней странице:
- номер сертификата, владелец, срок действия, по желанию — дата подписания
- и **логотип организации** (PNG/JPEG);
+- плашка «ДОКУМЕНТ ПОДПИСАН ЭЛЕКТРОННОЙ ПОДПИСЬЮ»: номер сертификата, владелец,
+ срок действия, по желанию — дата подписания и **логотип организации** (PNG/JPEG);
+- параметры выбираются в диалоге перед операцией: страницы (первая, последняя,
+ все или указанные, например «1,3-5»), дата, логотип; на каждой выбранной
+ странице — **отдельная плашка на каждого подписанта**;
- по умолчанию подписывается **оригинал**, а копия «имя (со штампом).pdf» —
отдельный файл **без подписи**, который после каждого подписания
пересоздаётся со **всеми подписантами** итоговой подписи (безопасно
@@ -98,16 +122,49 @@
### Прочее
-- **окно лога** со всеми операциями и отметками времени;
+- **лог операций** всегда виден в главном окне — все действия с отметками времени;
+- **окно «Настройки…»**: штамп времени (CAdES-T) и адрес службы TSA;
- **окно «О программе»**: версия, контакты автора, история версий,
проверка обновлений;
- **автообновление**: проверка новых версий на GitHub Releases при запуске
- (отключается) и обновление в один клик из окна «О программе» (Windows).
+ (отключается) и обновление в один клик из окна «О программе» (Windows);
+ прежний exe сохраняется резервной копией и восстанавливается, если новая
+ версия не запустилась.
+
+### Доверие, отзыв и время подписи
+
+Отчёт разделяет соответствие документу, значение подписи, привязку сертификата,
+цепочку доверия, статус отзыва и метку TSA. Корректная подпись сама по себе
+не подтверждает доверие к сертификату. Вложенный корень не становится доверенным:
+используются корни из хранилища ОС и явно выбранные в окне проверки сертификаты УЦ.
+Подписи сертификатов цепочки, CRL и OCSP проверяются управляемой библиотекой BouncyCastle.
+
+По умолчанию проверка работает без сети. Для отзыва используются вложенные CRL
+и выбранные файлы `.crl`/`.ocsp`. Сетевые запросы к HTTP(S) службам CRL/OCSP УЦ
+включаются отдельной галочкой (настройка запоминается и действует и для МЧД).
+Недоступный файл выбранного доверенного корня пропускается с предупреждением. Просроченные, неподписанные, неполные (в том числе
+delta/indirect CRL) или недоступные доказательства дают неопределённый статус,
+а не «сертификат действителен»; данные об отзыве проверяются для всех сертификатов
+цепочки, кроме доверенного корня. Недостающие промежуточные сертификаты
+должны присутствовать в CMS или хранилище ОС.
+
+`signing-time` — время, заявленное самим подписантом. Исторический срок сертификата
+оценивается по TSA только после проверки хеша, подписи, доверия и требуемого статуса
+отзыва TSA. При получении метки проверяются также nonce и соответствие запросу.
+
+МЧД перечитывается перед пакетным подписанием, проверяется и копируется как один
+снимок XML + SIG. Если сертификат руководителя истёк после выдачи МЧД, а метки TSA нет,
+время подписи не доказано — программа предупреждает и проверяет сертификат на дату выдачи.
+Конфликт с другим файлом МЧД останавливает операцию до сохранения подписи.
+Полномочия руководителя, содержание полномочий и отзыв самой МЧД в реестре
+не устанавливаются одной криптографической проверкой; при отсутствии подтверждений
+программа показывает предупреждение.
## Требования
- Windows 10/11 — готовый exe со страницы Releases (самодостаточный);
-- для ГОСТ-подписей — установленный **КриптоПро CSP** и личный сертификат;
+- для **создания** ГОСТ-подписей — установленный **КриптоПро CSP** и личный сертификат;
+- проверка и объединение ГОСТ-подписей работают **без КриптоПро** на Windows/Linux/macOS;
- сборка из исходников: .NET 8 SDK (Windows/Linux/macOS; вне Windows
подписание доступно алгоритмами платформы — RSA/ECDSA, ГОСТ — только
через CSP на Windows).
@@ -120,6 +177,12 @@ dotnet run --project src/SignService
dotnet run --project tests/SignService.Tests -c Release # тесты
```
+На рабочем компьютере используйте `-- --skip-user-store` в конце команды тестов,
+чтобы пропустить сценарии, изменяющие личное хранилище сертификатов и данные приложения.
+Полный набор запускается в изолированном CI на Linux и Windows. Управляемые тесты
+проверки ГОСТ, CRL/OCSP и TSA выполняются без КриптоПро и OpenSSL; интерфейс
+проверяется без экрана (Avalonia.Headless).
+
Публикация самодостаточного exe:
```bash
@@ -134,10 +197,11 @@ dotnet publish src/SignService -c Release -r win-x64 --self-contained true \
1. Запустите программу — в списке появятся сертификаты с закрытым ключом.
2. Выберите сертификат и режим подписи; при необходимости включите штамп
- времени и/или штамп на PDF.
+ на PDF (параметры спросит диалог), штамп времени — в «Настройках…».
3. Перетащите файлы в окно (или «Обзор…») и нажмите **«Подписать»** —
рядом с каждым файлом появится `имя.sig`.
-4. Операции с готовыми подписями — в меню **«Инструменты»**.
+4. Операции с готовыми подписями — в меню **«Инструменты»** и в контекстном
+ меню файла (ПКМ); ход работы виден в логе.
История изменений — в [CHANGELOG.md](CHANGELOG.md) и в окне «О программе».
@@ -160,14 +224,20 @@ src/SignService/
│ ├── CadesAttributes.cs # атрибуты CAdES-BES: signing-time, signing-cert-v2
│ ├── Streebog.cs # ГОСТ Р 34.11-2012 для certHash (векторы RFC 6986)
│ ├── CmsMerger.cs # объединение/разбор/сборка подписей на ASN.1
-│ ├── CmsExtractor.cs # файловые операции: извлечение, объединение, контейнер
+│ ├── CmsExtractor.cs # файловые операции: извлечение, объединение, разделение
+│ ├── SignatureVerifier.cs # проверка подписантов (BouncyCastle, ГОСТ без CSP)
+│ ├── CertificateValidator.cs # цепочка доверия (PKIX) и хранилища ОС
+│ ├── RevocationChecker.cs # отзыв сертификатов: CRL и OCSP
+│ ├── PowerOfAttorneyService.cs # МЧД: разбор EMCHD_1, проверка, копирование
│ ├── BerDer.cs # нормализация BER → definite-length
+│ ├── AtomicFile.cs # запись файлов через временный файл
│ ├── CertificateVault.cs # сертификат на ПК: PFX и хранилище Windows
│ ├── TimestampClient.cs # штамп времени TSA (RFC 3161) для CAdES-T
-│ ├── PdfStamper.cs # визуальный штамп на PDF (данные, дата, лого)
+│ ├── PdfStamper.cs # визуальный штамп на PDF (страницы, подписанты, лого)
│ ├── UpdateService.cs # автообновление через GitHub Releases
-│ └── AppSettings.cs # настройки (сертификат, режимы, штампы)
+│ └── AppSettings.cs # настройки (сертификат, режимы, штампы, доверие)
├── ViewModels/ # MVVM: главное окно, файлы, сертификаты
-└── Views/ # окна: главное, пароль, подтверждение, о программе
-tests/SignService.Tests/ # интеграционные тесты (запускаются в CI)
+└── Views/ # окна: главное, проверка ЭЦП, штамп, подписанты, настройки…
+tests/SignService.Tests/ # интеграционные тесты и смоук интерфейса (CI: Linux и Windows)
+.github/release/ # сборка архива релиза: скрипт и тексты лицензий
```
diff --git a/src/SignService/Properties/AssemblyInfo.cs b/src/SignService/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..6a26d2b
--- /dev/null
+++ b/src/SignService/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("SignService.Tests")]
diff --git a/src/SignService/Services/AppSettings.cs b/src/SignService/Services/AppSettings.cs
index 9e54390..091c51e 100644
--- a/src/SignService/Services/AppSettings.cs
+++ b/src/SignService/Services/AppSettings.cs
@@ -17,6 +17,18 @@ public class AppSettings
private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json");
+ private readonly string _settingsDirectory = SettingsDir;
+ private readonly string _settingsPath = SettingsPath;
+
+ public AppSettings() { }
+
+ /// Изолированное хранилище настроек (например, для интеграционных тестов).
+ public AppSettings(string settingsDirectory)
+ {
+ _settingsDirectory = Path.GetFullPath(settingsDirectory);
+ _settingsPath = Path.Combine(_settingsDirectory, "settings.json");
+ }
+
/// Отпечаток сертификата подписи по умолчанию.
public string? SignCertThumbprint { get; set; }
@@ -45,6 +57,12 @@ public class AppSettings
///
public bool StampSignCopy { get; set; }
+ /// Режим страниц штампа (Last/First/All/Custom).
+ public string StampPagesMode { get; set; } = "Last";
+
+ /// Номера страниц для режима Custom («1,3-5»).
+ public string? StampCustomPages { get; set; }
+
/// Путь к логотипу организации для штампа (PNG/JPEG).
public string? StampLogoPath { get; set; }
@@ -66,6 +84,37 @@ public class AppSettings
/// Путь к подписи руководителя (.sig) для МЧД.
public string? PoaSigPath { get; set; }
+ /// Дополнительные явно доверенные корни; хранилище ОС не изменяется.
+ public List VerificationTrustedRootPaths { get; set; } = new();
+
+ /// Разрешение запросов CRL/OCSP при проверке ЭЦП.
+ public bool VerificationAllowNetwork { get; set; }
+
+ ///
+ /// Параметры проверки ЭЦП из настроек: дополнительные доверенные корни и разрешение
+ /// сетевых запросов CRL/OCSP. Недоступный или повреждённый файл корня пропускается,
+ /// а описание добавляется в — проверка не прерывается.
+ ///
+ public VerificationOptions CreateVerificationOptions(ICollection? problems = null)
+ {
+ var roots = new List();
+ foreach (var path in VerificationTrustedRootPaths)
+ {
+ try
+ {
+ var certificate = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(File.ReadAllBytes(path))
+ ?? throw new InvalidDataException("в файле нет сертификата");
+ roots.Add(certificate.GetEncoded());
+ }
+ catch (Exception e)
+ {
+ problems?.Add($"Доверенный корень «{path}» пропущен: {e.Message}");
+ }
+ }
+
+ return new VerificationOptions { TrustedRoots = roots, AllowNetwork = VerificationAllowNetwork };
+ }
+
public static AppSettings Load()
{
try
@@ -85,8 +134,8 @@ public void Save()
{
try
{
- Directory.CreateDirectory(SettingsDir);
- File.WriteAllText(SettingsPath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
+ Directory.CreateDirectory(_settingsDirectory);
+ File.WriteAllText(_settingsPath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
catch
{
diff --git a/src/SignService/Services/AtomicFile.cs b/src/SignService/Services/AtomicFile.cs
new file mode 100644
index 0000000..a5e3050
--- /dev/null
+++ b/src/SignService/Services/AtomicFile.cs
@@ -0,0 +1,35 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace SignService.Services;
+
+internal static class AtomicFile
+{
+ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite = true,
+ CancellationToken cancellationToken = default)
+ {
+ var fullPath = Path.GetFullPath(path);
+ var temporary = Path.Combine(Path.GetDirectoryName(fullPath)!, ".signservice-" + Guid.NewGuid().ToString("N") + ".tmp");
+ try
+ {
+ await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None,
+ 8192, FileOptions.Asynchronous | FileOptions.WriteThrough))
+ {
+ await stream.WriteAsync(bytes, cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+ stream.Flush(flushToDisk: true);
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ File.Move(temporary, fullPath, overwrite);
+ }
+ finally
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ }
+
+ public static void Write(string path, byte[] bytes, bool overwrite = false) =>
+ WriteAsync(path, bytes, overwrite).GetAwaiter().GetResult();
+}
diff --git a/src/SignService/Services/BerDer.cs b/src/SignService/Services/BerDer.cs
index d8118ff..cbd8e69 100644
--- a/src/SignService/Services/BerDer.cs
+++ b/src/SignService/Services/BerDer.cs
@@ -20,13 +20,14 @@ internal static class BerDer
public static byte[] ToDefinite(ReadOnlySpan input)
{
var output = new MemoryStream();
- ConvertValue(input, output);
+ ConvertValue(input, output, 0);
return output.ToArray();
}
// Перекодирует один TLV из s в output; возвращает число прочитанных байтов.
- private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
+ private static int ConvertValue(ReadOnlySpan s, MemoryStream output, int depth)
{
+ if (depth > 128) throw new AsnContentException("Превышена допустимая вложенность ASN.1.");
if (s.Length < 2)
throw new AsnContentException("Обрезанные ASN.1-данные.");
@@ -66,7 +67,7 @@ private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
break;
}
- p += ConvertValue(s[p..], inner);
+ p += ConvertValue(s[p..], inner, depth + 1);
}
content = inner.ToArray();
@@ -88,7 +89,7 @@ private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
length = (length << 8) | s[p++];
}
- if (p + length > s.Length)
+ if (length < 0 || length > s.Length - p)
throw new AsnContentException("Длина ASN.1-значения выходит за пределы данных.");
if (constructed)
@@ -97,7 +98,7 @@ private static int ConvertValue(ReadOnlySpan s, MemoryStream output)
var children = s.Slice(p, length);
var q = 0;
while (q < children.Length)
- q += ConvertValue(children[q..], inner);
+ q += ConvertValue(children[q..], inner, depth + 1);
content = inner.ToArray();
}
else
diff --git a/src/SignService/Services/CertificateValidator.cs b/src/SignService/Services/CertificateValidator.cs
new file mode 100644
index 0000000..4d6f53e
--- /dev/null
+++ b/src/SignService/Services/CertificateValidator.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using Org.BouncyCastle.Pkix;
+using Org.BouncyCastle.Security.Certificates;
+using Org.BouncyCastle.Utilities.Collections;
+using Org.BouncyCastle.X509;
+using Org.BouncyCastle.X509.Store;
+using Certificate = Org.BouncyCastle.X509.X509Certificate;
+
+namespace SignService.Services;
+
+internal sealed record CertificateValidation(VerificationCheck Trust, VerificationCheck Revocation);
+
+internal static class CertificateValidator
+{
+ public static CertificateValidation Validate(Certificate certificate, IReadOnlyList embedded,
+ IReadOnlyList crls, VerificationOptions options, CancellationToken cancellationToken)
+ {
+ var notChecked = VerificationCheck.NotChecked("Проверка отключена.");
+ if (!options.CheckCertificateTrust && !options.CheckRevocation)
+ return new CertificateValidation(notChecked, notChecked);
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ certificate.CheckValidity(options.ValidationTime.UtcDateTime);
+ var usage = certificate.GetKeyUsage();
+ if (usage is not null && !usage[0] && !usage[1])
+ return new CertificateValidation(VerificationCheck.Invalid("Сертификат не разрешает подписание."),
+ VerificationCheck.Unknown("Нет допустимой цепочки для проверки отзыва."));
+ var parser = new X509CertificateParser();
+ var roots = options.TrustedRoots.Select(parser.ReadCertificate).ToList();
+ var candidates = embedded.Concat(options.ExtraCertificates.Select(parser.ReadCertificate)).ToList();
+ if (options.UseSystemTrustStore)
+ {
+ var system = SystemStores();
+ roots.AddRange(system.Roots);
+ candidates.AddRange(system.Intermediates);
+ }
+ // A root supplied inside a CMS is an intermediate candidate, never a trust anchor.
+ roots = roots.Distinct().ToList();
+ if (roots.Count == 0)
+ return new CertificateValidation(options.CheckCertificateTrust
+ ? VerificationCheck.Unknown("Нет доверенного корневого сертификата. Вложенный корень не создаёт доверия.") : notChecked,
+ options.CheckRevocation ? VerificationCheck.Unknown("Без цепочки до доверенного корня отзыв не подтверждён.") : notChecked);
+ var parameters = new PkixBuilderParameters(roots.Select(c => new TrustAnchor(c, null)).ToHashSet(),
+ new X509CertStoreSelector { Certificate = certificate })
+ {
+ Date = options.ValidationTime.UtcDateTime, IsRevocationEnabled = false, MaxPathLength = 8,
+ };
+ parameters.AddStoreCert(CollectionUtilities.CreateStore(candidates.Append(certificate).Concat(roots)));
+ var path = new PkixCertPathBuilder().Build(parameters);
+ var root = path.TrustAnchor.TrustedCert;
+ root.CheckValidity(options.ValidationTime.UtcDateTime);
+ var chain = path.CertPath.Certificates.ToList();
+ if (chain.Count == 0 || !chain[^1].Equals(root)) chain.Add(root);
+ return new CertificateValidation(options.CheckCertificateTrust
+ ? VerificationCheck.Valid($"Цепочка до доверенного корня проверена на {options.ValidationTime:dd.MM.yyyy HH:mm:ss zzz}.") : notChecked,
+ options.CheckRevocation ? RevocationChecker.Check(chain, crls, options, cancellationToken) : notChecked);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (CertificateExpiredException)
+ {
+ return new CertificateValidation(VerificationCheck.Invalid("Срок сертификата истёк на момент проверки."),
+ options.CheckRevocation ? VerificationCheck.Unknown("Отзыв не проверен: сертификат просрочен.") : notChecked);
+ }
+ catch (CertificateNotYetValidException)
+ {
+ return new CertificateValidation(VerificationCheck.Invalid("Сертификат ещё не действовал на момент проверки."),
+ options.CheckRevocation ? VerificationCheck.Unknown("Отзыв не проверен: сертификат ещё не действовал.") : notChecked);
+ }
+ catch (Exception e)
+ {
+ return new CertificateValidation(options.CheckCertificateTrust
+ ? VerificationCheck.Unknown("Не удалось построить допустимую цепочку до доверенного корня: " + e.Message) : notChecked,
+ options.CheckRevocation ? VerificationCheck.Unknown("Отзыв не подтверждён без допустимой цепочки.") : notChecked);
+ }
+ }
+
+ // Хранилища ОС читаются не на каждую подпись: пакетная проверка и объединение
+ // обращаются к ним многократно. Короткое время жизни подхватывает новые корни.
+ private static readonly TimeSpan SystemStoreLifetime = TimeSpan.FromMinutes(2);
+ private static readonly object SystemStoreLock = new();
+ private static (DateTime LoadedAt, IReadOnlyList Roots, IReadOnlyList Intermediates)? _systemStores;
+
+ /// Сколько раз читались хранилища ОС (для тестов кеша).
+ internal static int SystemStoreLoads { get; private set; }
+
+ private static (IReadOnlyList Roots, IReadOnlyList Intermediates) SystemStores()
+ {
+ lock (SystemStoreLock)
+ {
+ if (_systemStores is { } cached && DateTime.UtcNow - cached.LoadedAt < SystemStoreLifetime)
+ return (cached.Roots, cached.Intermediates);
+
+ var roots = new List();
+ var intermediates = new List();
+ foreach (var location in new[] { StoreLocation.CurrentUser, StoreLocation.LocalMachine })
+ {
+ ReadStore(StoreName.Root, location, roots);
+ ReadStore(StoreName.CertificateAuthority, location, intermediates);
+ }
+
+ SystemStoreLoads++;
+ _systemStores = (DateTime.UtcNow, roots, intermediates);
+ return (roots, intermediates);
+ }
+ }
+
+ private static void ReadStore(StoreName name, StoreLocation location, List destination)
+ {
+ try
+ {
+ using var store = new X509Store(name, location);
+ store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
+ foreach (var cert in store.Certificates)
+ using (cert)
+ try { destination.Add(new X509CertificateParser().ReadCertificate(cert.RawData)); }
+ catch (Exception) { /* Skip unreadable entries; they cannot establish trust. */ }
+ }
+ catch (Exception) { /* A platform may not expose both store locations. */ }
+ }
+}
diff --git a/src/SignService/Services/CertificateVault.cs b/src/SignService/Services/CertificateVault.cs
index 8029c4b..ed1a18a 100644
--- a/src/SignService/Services/CertificateVault.cs
+++ b/src/SignService/Services/CertificateVault.cs
@@ -20,9 +20,18 @@ namespace SignService.Services;
///
public class CertificateVault
{
- private static readonly string VaultDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "SignService", "certificates");
+ private readonly string _vaultDirectory;
+
+ /// Папка PFX; null — %AppData%/SignService/certificates
+ /// (другая папка — для изолированных интеграционных тестов).
+ public CertificateVault(string? vaultDirectory = null)
+ {
+ _vaultDirectory = vaultDirectory is not null
+ ? Path.GetFullPath(vaultDirectory)
+ : Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "SignService", "certificates");
+ }
///
/// Сохраняет сертификат с закрытым ключом в защищённый паролем PFX.
@@ -47,9 +56,9 @@ public SavedCertificateInfo Save(X509Certificate2 certificate, string password,
+ "Сохранить такой сертификат на компьютер нельзя.", e);
}
- Directory.CreateDirectory(VaultDir);
+ Directory.CreateDirectory(_vaultDirectory);
var fileName = certificate.Thumbprint + ".pfx";
- File.WriteAllBytes(Path.Combine(VaultDir, fileName), pfx);
+ File.WriteAllBytes(Path.Combine(_vaultDirectory, fileName), pfx);
var info = new SavedCertificateInfo
{
@@ -72,7 +81,7 @@ public SavedCertificateInfo Save(X509Certificate2 certificate, string password,
/// Сохранённые сертификаты, PFX-файлы которых существуют на диске.
public IReadOnlyList List(AppSettings settings) =>
settings.SavedCertificates
- .Where(c => File.Exists(Path.Combine(VaultDir, c.FileName)))
+ .Where(c => File.Exists(Path.Combine(_vaultDirectory, c.FileName)))
.ToList();
/// Открытая часть сохранённого сертификата (без ключа и без пароля).
@@ -85,7 +94,7 @@ public static X509Certificate2 PublicPart(SavedCertificateInfo info) =>
///
public X509Certificate2 Load(SavedCertificateInfo info, string password)
{
- var path = Path.Combine(VaultDir, info.FileName);
+ var path = Path.Combine(_vaultDirectory, info.FileName);
if (!File.Exists(path))
throw new InvalidOperationException("Файл сохранённого сертификата не найден: " + path);
@@ -169,7 +178,7 @@ public void RemoveFromStore(string thumbprint, AppSettings settings)
/// Загрузка PFX с флагами для установки в хранилище (ключ сохраняется).
private X509Certificate2 LoadForStore(SavedCertificateInfo info, string password)
{
- var path = Path.Combine(VaultDir, info.FileName);
+ var path = Path.Combine(_vaultDirectory, info.FileName);
try
{
return new X509Certificate2(path, password,
@@ -184,7 +193,7 @@ private X509Certificate2 LoadForStore(SavedCertificateInfo info, string password
/// Удаляет сохранённый сертификат с компьютера (PFX-файл и запись).
public void Delete(SavedCertificateInfo info, AppSettings settings)
{
- var path = Path.Combine(VaultDir, info.FileName);
+ var path = Path.Combine(_vaultDirectory, info.FileName);
if (File.Exists(path))
{
// затираем содержимое перед удалением — в файле лежал закрытый ключ
diff --git a/src/SignService/Services/CmsExtractor.cs b/src/SignService/Services/CmsExtractor.cs
index 9f698ab..9c6c51c 100644
--- a/src/SignService/Services/CmsExtractor.cs
+++ b/src/SignService/Services/CmsExtractor.cs
@@ -13,6 +13,41 @@ namespace SignService.Services;
///
public static class CmsExtractor
{
+ public sealed record SignerInfo(string Id, string DisplayName);
+
+ public sealed record RemoveSignerResult(string OutputPath, int SignerCount);
+
+ /// Подписанты файла подписи — для выбора исключаемого.
+ public static IReadOnlyList ListSigners(string signaturePath) =>
+ CmsMerger.GetSigners(File.ReadAllBytes(signaturePath))
+ .Select((s, index) => new SignerInfo(s.Id, $"{index + 1}. {s.Name} — сертификат {s.CertificateId}"))
+ .ToList();
+
+ ///
+ /// Разделяет групповую подпись на отдельные откреплённые .sig по подписантам;
+ /// вложенный документ прикреплённого контейнера сохраняется рядом.
+ ///
+ public static ExtractionResult SplitSignatureFile(string signaturePath)
+ {
+ if (CmsMerger.CountSigners(File.ReadAllBytes(signaturePath)) < 2)
+ throw new InvalidOperationException("В файле меньше двух подписантов — это не групповая подпись.");
+ return ExtractToFiles(signaturePath);
+ }
+
+ ///
+ /// Создаёт рядом новый файл «имя (без подписанта).sig» без выбранного подписанта.
+ /// Исходный файл не меняется.
+ ///
+ public static RemoveSignerResult RemoveSignerFromFile(string signaturePath, string signerId)
+ {
+ var signature = CmsMerger.RemoveSigner(File.ReadAllBytes(signaturePath), signerId);
+ var directory = Path.GetDirectoryName(signaturePath) ?? ".";
+ var output = UniquePath(directory,
+ Path.GetFileNameWithoutExtension(signaturePath) + " (без подписанта).sig", new[] { signaturePath });
+ AtomicFile.Write(output, signature);
+ return new RemoveSignerResult(output, CmsMerger.CountSigners(signature));
+ }
+
public sealed record ExtractionResult(
string ContainerName,
bool WasAttached,
@@ -102,7 +137,7 @@ public static MergeFilesResult MergeSignatureFiles(IReadOnlyList paths)
: firstName;
var outputPath = UniquePath(directory, $"{baseName} (объединённая).sig", paths);
- File.WriteAllBytes(outputPath, merged);
+ AtomicFile.Write(outputPath, merged);
return new MergeFilesResult(
outputPath, info.SignerNames.Count, info.HasContent, documentNote, excluded, unverified);
@@ -135,15 +170,15 @@ public static BuildContainerResult BuildContainer(
$"Рядом с документом нет файла «{Path.GetFileName(documentPath)}.sig» — выберите подписи вручную.");
var inputs = paths.Select(File.ReadAllBytes).ToList();
- var merged = CmsMerger.MergeForDocument(inputs, document);
- var container = CmsMerger.AttachContent(merged.Signature, document);
+ var merged = CmsMerger.MergeForDocument(inputs, document, attach: true);
+ var container = merged.Signature;
var directory = Path.GetDirectoryName(documentPath) ?? ".";
var outputPath = UniquePath(
directory,
Path.GetFileName(documentPath) + " (контейнер).sig",
paths.Append(documentPath).ToList());
- File.WriteAllBytes(outputPath, container);
+ AtomicFile.Write(outputPath, container);
return new BuildContainerResult(
outputPath, merged.SignerCount, merged.ExcludedSigners, merged.UnverifiedSigners);
diff --git a/src/SignService/Services/CmsMerger.cs b/src/SignService/Services/CmsMerger.cs
index eaffbaa..78b9bd2 100644
--- a/src/SignService/Services/CmsMerger.cs
+++ b/src/SignService/Services/CmsMerger.cs
@@ -40,14 +40,17 @@ public sealed record MergeResult(
IReadOnlyList UnverifiedSigners);
///
- /// Объединяет подписи с проверкой соответствия каждой из них документу:
- /// у подписанта из подписанных атрибутов берётся messageDigest и сравнивается
- /// с хешем документа (ГОСТ Р 34.11-2012 — своей реализацией Стрибога, SHA-1/256/384/512 —
- /// штатно). Подписи, сделанные под другим файлом или прежней версией документа,
- /// ИСКЛЮЧАЮТСЯ из результата (их список — в ExcludedSigners). Подписи, чей алгоритм
- /// хеша проверить нечем, сохраняются и перечисляются в UnverifiedSigners.
+ /// Проверяет хеш документа, значение подписи и привязку сертификата каждого
+ /// подписанта. Подписи, которые однозначно не соответствуют документу (другая
+ /// версия, повреждённое значение, чужой сертификат), исключаются. Подписи, которые
+ /// проверить не удалось (неизвестный алгоритм, нет сертификата, ошибка библиотеки),
+ /// сохраняются с указанием в UnverifiedSigners. Доверие УЦ проверяется отдельно.
///
- public static MergeResult MergeForDocument(IReadOnlyList signatures, byte[] document)
+ ///
+ /// true — результат прикреплённый (с этим документом), false — откреплённый,
+ /// null — прикреплённый, если прикреплённым был хотя бы один вход.
+ ///
+ public static MergeResult MergeForDocument(IReadOnlyList signatures, byte[] document, bool? attach = null)
{
if (signatures.Count == 0)
throw new ArgumentException("Нет подписей для объединения.", nameof(signatures));
@@ -56,21 +59,40 @@ public static MergeResult MergeForDocument(IReadOnlyList signatures, byt
// Справочник сертификатов всех входов — для имени подписанта в отчёте.
var certIndex = BuildCertIndex(parsed);
- var hashCache = new Dictionary();
var excluded = new List();
var unverified = new List();
+ var contentTypes = parsed.Select(p => new AsnReader(p.EncapContentInfo, AsnEncodingRules.BER)
+ .ReadSequence().ReadObjectIdentifier()).Distinct().ToList();
+ if (contentTypes.Count != 1)
+ throw new InvalidOperationException("Нельзя объединить подписи с разными типами содержимого CMS.");
+ var allCertificates = DedupeBytes(parsed.SelectMany(p => p.Certificates));
+ var attachOutput = attach ?? parsed.Any(p => p.HasContent);
+
+ // Вложенный документ входов не используется: каждый подписант проверяется
+ // по текущему документу, который хешируется один раз на алгоритм.
+ var detachedEncap = StripContent(parsed[0].EncapContentInfo);
+ var digests = new DocumentDigests(document);
foreach (var p in parsed)
{
+ p.EncapContentInfo = detachedEncap;
+ p.HasContent = false;
for (var i = p.Signers.Count - 1; i >= 0; i--)
{
- var (_, der) = p.Signers[i];
- var check = CheckSignerAgainstDocument(der, document, hashCache);
- if (check == SignerDocMatch.Match)
+ var single = new ParsedSignedData { Version = p.Version, EncapContentInfo = detachedEncap };
+ single.Signers.Add(p.Signers[i]);
+ single.DigestAlgorithms.AddRange(p.DigestAlgorithms);
+ single.Certificates.AddRange(allCertificates);
+ var result = SignatureVerifier.VerifyWithDigests(BuildMerged(new List { single }),
+ digests, VerificationOptions.CryptographyOnly);
+ var check = result.Signers.SingleOrDefault();
+ if (check?.CryptographicallyValid == true)
continue;
- var name = SignerDisplayName(der, certIndex);
- if (check == SignerDocMatch.Mismatch)
+ var name = SignerDisplayName(p.Signers[i].Der, certIndex);
+ if (check is not null && (check.Document.State == VerificationState.Invalid
+ || check.Signature.State == VerificationState.Invalid
+ || check.CertificateBinding.State == VerificationState.Invalid))
{
excluded.Add(name);
p.Signers.RemoveAt(i);
@@ -84,7 +106,13 @@ public static MergeResult MergeForDocument(IReadOnlyList signatures, byt
if (parsed.All(p => p.Signers.Count == 0))
throw new InvalidOperationException(
- "Все объединяемые подписи не соответствуют текущему содержимому документа.");
+ "Все объединяемые подписи повреждены или не соответствуют текущему содержимому документа.");
+
+ if (attachOutput)
+ {
+ parsed[0].EncapContentInfo = EncapWithContent(contentTypes[0], document);
+ parsed[0].HasContent = true;
+ }
var merged = BuildMerged(parsed);
return new MergeResult(
@@ -109,7 +137,21 @@ public static byte[] Merge(IReadOnlyList signatures)
private static byte[] BuildMerged(List parsed)
{
-
+ var types = parsed.Select(p => new AsnReader(p.EncapContentInfo, AsnEncodingRules.BER)
+ .ReadSequence().ReadObjectIdentifier()).Distinct().Count();
+ if (types != 1) throw new InvalidOperationException("Типы содержимого CMS различаются.");
+ var attached = parsed.Where(p => p.HasContent).ToList();
+ if (attached.Select(p => Convert.ToBase64String(p.EncapContentInfo)).Distinct().Count() > 1)
+ {
+ var contents = attached.Select(p =>
+ {
+ var encap = new AsnReader(p.EncapContentInfo, AsnEncodingRules.BER).ReadSequence();
+ encap.ReadObjectIdentifier();
+ return ReadOctetStringData(encap.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)));
+ }).ToList();
+ if (contents.Skip(1).Any(c => !c.AsSpan().SequenceEqual(contents[0])))
+ throw new InvalidOperationException("Прикреплённые подписи содержат разные документы.");
+ }
var version = parsed.Max(p => p.Version);
var digestAlgorithms = DedupeBytes(parsed.SelectMany(p => p.DigestAlgorithms));
var certificates = DedupeBytes(parsed.SelectMany(p => p.Certificates));
@@ -190,6 +232,50 @@ public static ContainerInfo Inspect(byte[] signature)
return new ContainerInfo(parsed.HasContent, names);
}
+ ///
+ /// Подписант контейнера: Id — DER SignerIdentifier в hex (устойчив при
+ /// одинаковых ФИО), CertificateId — серийный номер сертификата (или SKI).
+ ///
+ public sealed record SignerDescriptor(string Id, string Name, string CertificateId);
+
+ public static IReadOnlyList GetSigners(byte[] signature)
+ {
+ var parsed = Parse(Normalize(signature));
+ var index = BuildCertIndex(new List { parsed });
+ return parsed.Signers.Select(s =>
+ {
+ var reader = new AsnReader(Convert.FromHexString(s.SidKey), AsnEncodingRules.BER);
+ string identifier;
+ if (reader.PeekTag().TagClass == TagClass.Universal)
+ {
+ var sid = reader.ReadSequence();
+ sid.ReadEncodedValue(); // issuer
+ identifier = Convert.ToHexString(sid.ReadIntegerBytes().Span);
+ }
+ else
+ {
+ identifier = s.SidKey; // [0] subjectKeyIdentifier
+ }
+
+ return new SignerDescriptor(s.SidKey, SignerDisplayName(s.Der, index), identifier);
+ }).ToList();
+ }
+
+ ///
+ /// Удаляет SignerInfo по идентификатору (см. ), сохраняя
+ /// документ, сертификаты и остальных подписантов. Последнего подписанта удалить нельзя.
+ ///
+ public static byte[] RemoveSigner(byte[] signature, string signerId)
+ {
+ var parsed = Parse(Normalize(signature));
+ if (!parsed.Signers.Any(s => s.SidKey == signerId))
+ throw new InvalidOperationException("Выбранного подписанта нет в этом файле подписи.");
+ parsed.Signers.RemoveAll(s => s.SidKey == signerId);
+ if (parsed.Signers.Count == 0)
+ throw new InvalidOperationException("Нельзя исключить последнего подписанта: подпись станет пустой.");
+ return BuildMerged(new List { parsed });
+ }
+
///
/// Извлекает вложенный документ из прикреплённой подписи;
/// null — если подпись откреплённая (документа внутри нет).
@@ -388,20 +474,25 @@ public static byte[] AttachContent(byte[] signature, byte[] document)
var parsed = Parse(Normalize(signature));
// Тип содержимого сохраняем из исходной подписи (обычно id-data).
- var encap = new AsnReader(parsed.EncapContentInfo, AsnEncodingRules.BER).ReadSequence();
- var contentType = encap.ReadObjectIdentifier();
+ var contentType = new AsnReader(parsed.EncapContentInfo, AsnEncodingRules.BER)
+ .ReadSequence().ReadObjectIdentifier();
+ parsed.EncapContentInfo = EncapWithContent(contentType, document);
+ parsed.HasContent = true;
+ return BuildMerged(new List { parsed });
+ }
+ // EncapsulatedContentInfo с вложенным документом: SEQUENCE { eContentType, [0] EXPLICIT OCTET STRING }.
+ private static byte[] EncapWithContent(string contentType, byte[] document)
+ {
var writer = new AsnWriter(AsnEncodingRules.DER);
- using (writer.PushSequence()) // EncapsulatedContentInfo
+ using (writer.PushSequence())
{
writer.WriteObjectIdentifier(contentType);
- using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0))) // [0] EXPLICIT
+ using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0)))
writer.WriteOctetString(document);
}
- parsed.EncapContentInfo = writer.Encode();
- parsed.HasContent = true;
- return BuildMerged(new List { parsed });
+ return writer.Encode();
}
// EncapContentInfo без eContent: SEQUENCE { eContentType } — для откреплённой подписи.
@@ -492,114 +583,6 @@ private static ParsedSignedData Parse(byte[] cms)
}
}
- /// Итог сверки подписи с документом.
- public enum DocMatch
- {
- Match,
- Mismatch,
- Unknown,
- }
-
- ///
- /// Сверяет подпись с документом по messageDigest всех подписантов
- /// (для ГОСТ — Стрибогом, без криптопровайдера). Mismatch — хотя бы один
- /// подписант подписал другой файл; Match — все совпали; Unknown — проверить нечем.
- ///
- public static DocMatch CheckAgainstDocument(byte[] signature, byte[] document)
- {
- var parsed = Parse(Normalize(signature));
- var cache = new Dictionary();
- var anyMatch = false;
- foreach (var (_, der) in parsed.Signers)
- {
- switch (CheckSignerAgainstDocument(der, document, cache))
- {
- case SignerDocMatch.Mismatch:
- return DocMatch.Mismatch;
- case SignerDocMatch.Match:
- anyMatch = true;
- break;
- }
- }
-
- return anyMatch ? DocMatch.Match : DocMatch.Unknown;
- }
-
- private enum SignerDocMatch
- {
- Match,
- Mismatch,
- Unknown,
- }
-
- // Сверяет подпись с документом по messageDigest из подписанных атрибутов.
- // Без атрибутов (голый PKCS#7) или с неподдерживаемым алгоритмом хеша — Unknown.
- private static SignerDocMatch CheckSignerAgainstDocument(
- byte[] signerInfoDer, byte[] document, Dictionary hashCache)
- {
- try
- {
- var (digestOid, messageDigest) = InspectSigner(signerInfoDer);
- if (messageDigest is null)
- return SignerDocMatch.Unknown;
-
- if (!hashCache.TryGetValue(digestOid, out var docHash))
- {
- docHash = HashDocument(digestOid, document);
- hashCache[digestOid] = docHash;
- }
-
- if (docHash is null)
- return SignerDocMatch.Unknown;
-
- return docHash.AsSpan().SequenceEqual(messageDigest)
- ? SignerDocMatch.Match
- : SignerDocMatch.Mismatch;
- }
- catch (AsnContentException)
- {
- return SignerDocMatch.Unknown;
- }
- }
-
- // (OID алгоритма хеша, значение messageDigest из подписанных атрибутов или null).
- private static (string DigestOid, byte[]? MessageDigest) InspectSigner(byte[] signerInfoDer)
- {
- var signerInfo = new AsnReader(signerInfoDer, AsnEncodingRules.BER).ReadSequence();
- signerInfo.ReadInteger(); // version
- signerInfo.ReadEncodedValue(); // sid
- var digestAlgorithm = signerInfo.ReadSequence(); // AlgorithmIdentifier
- var digestOid = digestAlgorithm.ReadObjectIdentifier();
-
- var signedAttrsTag = new Asn1Tag(TagClass.ContextSpecific, 0, isConstructed: true);
- if (!signerInfo.HasData || signerInfo.PeekTag() != signedAttrsTag)
- return (digestOid, null);
-
- var attrs = signerInfo.ReadSetOf(signedAttrsTag);
- while (attrs.HasData)
- {
- var attr = attrs.ReadSequence();
- if (attr.ReadObjectIdentifier() == "1.2.840.113549.1.9.4") // messageDigest
- {
- var values = attr.ReadSetOf();
- return (digestOid, values.ReadOctetString());
- }
- }
-
- return (digestOid, null);
- }
-
- private static byte[]? HashDocument(string digestOid, byte[] document) => digestOid switch
- {
- "1.2.643.7.1.1.2.2" => Streebog.Hash256(document), // ГОСТ Р 34.11-2012 (256)
- "1.2.643.7.1.1.2.3" => Streebog.Hash512(document), // ГОСТ Р 34.11-2012 (512)
- "2.16.840.1.101.3.4.2.1" => SHA256.HashData(document),
- "2.16.840.1.101.3.4.2.2" => SHA384.HashData(document),
- "2.16.840.1.101.3.4.2.3" => SHA512.HashData(document),
- "1.3.14.3.2.26" => SHA1.HashData(document),
- _ => null, // ГОСТ 34.11-94 и прочее — не проверяем
- };
-
// «Кому выдан» (CN) сертификата подписанта по его SignerIdentifier, либо номер-заглушка.
private static string SignerDisplayName(
byte[] signerInfoDer, IReadOnlyDictionary certIndex)
diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs
index 365315d..ecc39dc 100644
--- a/src/SignService/Services/DocumentSigner.cs
+++ b/src/SignService/Services/DocumentSigner.cs
@@ -55,11 +55,25 @@ public sealed record SignOptions
/// Путь к картинке логотипа для штампа (PNG/JPEG) или null.
public string? StampLogoPath { get; init; }
+ ///
+ /// Полные параметры штампа (страницы и т.д.); если null — собираются
+ /// из StampWithDate/StampLogoPath (последняя страница).
+ ///
+ public PdfStamper.StampOptions? StampParameters { get; init; }
+
///
/// Доверенность МЧД: файлы XML и подписи руководителя копируются в каталог
/// подписанного документа (как это делает Контур), номер попадает в PDF-штамп.
///
public PowerOfAttorneyService.PoaInfo? PowerOfAttorney { get; init; }
+
+ public VerificationOptions? PoaVerificationOptions { get; init; }
+
+ ///
+ /// МЧД, уже проверенная для этого сертификата (один раз на пакет файлов):
+ /// повторная полная проверка не выполняется, копируется именно этот снимок.
+ ///
+ internal PowerOfAttorneyService.PoaPackage? PreparedPowerOfAttorney { get; init; }
}
///
@@ -112,19 +126,31 @@ public async Task SignFileAsync(
SignOptions options,
CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+ var poaPackage = options.PreparedPowerOfAttorney ?? (options.PowerOfAttorney is { } selectedPoa
+ ? PowerOfAttorneyService.Prepare(selectedPoa, certificate, options.PoaVerificationOptions) : null);
+ if (poaPackage?.Check.State == PowerOfAttorneyService.CheckState.Error)
+ throw new InvalidOperationException(poaPackage.Check.Message);
+ if (poaPackage is not null) PowerOfAttorneyService.CheckCopyTargets(poaPackage, filePath);
// Режим «подписывать копию со штампом»: штамп меняет содержимое PDF,
// поэтому копия создаётся ДО подписания и подписывается именно она.
// В режиме «копия отдельно» подписывается ОРИГИНАЛ, а штампованная копия
// (без подписи, со всеми подписантами) создаётся ниже, после подписания.
var targetPath = filePath;
var stampAsCopy = options.Stamp && !options.StampSignCopy && PdfStamper.IsPdf(filePath);
+ var stampOptions = options.StampParameters
+ ?? new PdfStamper.StampOptions { WithDate = options.StampWithDate, LogoPath = options.StampLogoPath };
+ // Номер МЧД — из проверенного снимка файлов, а не из сохранённых ранее сведений.
+ stampOptions = stampOptions with
+ {
+ PoaNumber = stampOptions.PoaNumber ?? poaPackage?.Info.Number,
+ };
if (options.Stamp && options.StampSignCopy && PdfStamper.IsPdf(filePath))
{
try
{
targetPath = PdfStamper.CreateStampedCopy(
- filePath, certificate, options.StampWithDate, options.StampLogoPath, DateTime.Now,
- options.PowerOfAttorney?.Number);
+ filePath, new[] { certificate }, stampOptions, DateTime.Now);
}
catch (Exception e)
{
@@ -163,9 +189,10 @@ public async Task SignFileAsync(
int signerCount;
IReadOnlyList excluded;
IReadOnlyList unverified;
+ byte[] output;
if (inputs.Count == 1)
{
- await File.WriteAllBytesAsync(signaturePath, own, cancellationToken);
+ output = own;
signerCount = 1;
excluded = Array.Empty();
unverified = Array.Empty();
@@ -174,15 +201,18 @@ public async Task SignFileAsync(
{
// Объединение с проверкой: подписи под другим файлом или прежней версией
// документа исключаются — иначе портал отклонит весь контейнер.
- var merged = CmsMerger.MergeForDocument(inputs, data);
- await File.WriteAllBytesAsync(signaturePath, merged.Signature, cancellationToken);
+ var merged = CmsMerger.MergeForDocument(inputs, data, attach: !options.Detached);
+ output = merged.Signature;
signerCount = merged.SignerCount;
excluded = merged.ExcludedSigners;
unverified = merged.UnverifiedSigners;
}
- if (options.PowerOfAttorney is { } poa)
- PowerOfAttorneyService.CopyNextToDocument(poa, targetPath);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!(await File.ReadAllBytesAsync(targetPath, cancellationToken)).AsSpan().SequenceEqual(data))
+ throw new IOException("Документ изменился во время подписания. Подпись не сохранена; повторите операцию.");
+ if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath);
+ await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken);
// Режим «копия отдельно»: штампованная копия БЕЗ подписи, со всеми
// подписантами итогового .sig — пересоздаётся после каждого подписания.
@@ -197,8 +227,7 @@ public async Task SignFileAsync(
? signerCerts
: new List { certificate };
stampedCopyPath = PdfStamper.CreateStampedCopy(
- filePath, stampCerts, options.StampWithDate, options.StampLogoPath, DateTime.Now,
- options.PowerOfAttorney?.Number);
+ filePath, stampCerts, stampOptions, DateTime.Now);
foreach (var c in signerCerts)
c.Dispose();
}
@@ -355,21 +384,11 @@ private static byte[][] BuildChainBlobs(X509Certificate2 certificate)
///
/// Проверяет откреплённую подпись для файла (только криптографическую
- /// корректность, без проверки доверия цепочки). ГОСТ-подписи вне Windows
- /// проверить нельзя — SignedCms их не разбирает.
+ /// корректность, без проверки доверия цепочки). ГОСТ поддерживается
+ /// управляемой библиотекой на любой платформе без криптопровайдера.
///
public bool VerifyDetached(byte[] data, byte[] signature)
{
- var signedCms = new SignedCms(new ContentInfo(data), detached: true);
- signedCms.Decode(signature);
- try
- {
- signedCms.CheckSignature(verifySignatureOnly: true);
- return true;
- }
- catch (System.Security.Cryptography.CryptographicException)
- {
- return false;
- }
+ return SignatureVerifier.Verify(signature, data, VerificationOptions.CryptographyOnly).CryptographicallyValid;
}
}
diff --git a/src/SignService/Services/PdfStamper.cs b/src/SignService/Services/PdfStamper.cs
index 983326f..cf46e99 100644
--- a/src/SignService/Services/PdfStamper.cs
+++ b/src/SignService/Services/PdfStamper.cs
@@ -28,10 +28,31 @@ static PdfStamper()
public static bool IsPdf(string filePath) =>
Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase);
- ///
- /// Создаёт штампованную копию PDF. Возвращает путь к копии
- /// («имя (со штампом).pdf» рядом с исходным файлом; существующая копия обновляется).
- ///
+ /// На каких страницах ставить штампы.
+ public enum StampPages
+ {
+ Last,
+ First,
+ All,
+ Custom,
+ }
+
+ /// Параметры визуального штампа.
+ public sealed record StampOptions
+ {
+ public bool WithDate { get; init; } = true;
+
+ public string? LogoPath { get; init; }
+
+ public string? PoaNumber { get; init; }
+
+ public StampPages Pages { get; init; } = StampPages.Last;
+
+ /// Номера страниц для режима Custom, например «1,3-5».
+ public string? CustomPages { get; init; }
+ }
+
+ /// Совместимая обёртка: один подписант, штамп на последней странице.
public static string CreateStampedCopy(
string pdfPath,
X509Certificate2 certificate,
@@ -39,13 +60,10 @@ public static string CreateStampedCopy(
string? logoPath,
DateTime signTime,
string? poaNumber = null)
- => CreateStampedCopy(pdfPath, new[] { certificate }, withDate, logoPath, signTime, poaNumber);
+ => CreateStampedCopy(pdfPath, new[] { certificate },
+ new StampOptions { WithDate = withDate, LogoPath = logoPath, PoaNumber = poaNumber }, signTime);
- ///
- /// Штампованная копия с несколькими подписантами (по блоку данных на каждого) —
- /// используется в режиме «копия отдельно», когда штамп отражает всех
- /// подписантов итоговой подписи.
- ///
+ /// Совместимая обёртка: несколько подписантов, последняя страница.
public static string CreateStampedCopy(
string pdfPath,
IReadOnlyList certificates,
@@ -53,6 +71,19 @@ public static string CreateStampedCopy(
string? logoPath,
DateTime signTime,
string? poaNumber = null)
+ => CreateStampedCopy(pdfPath, certificates,
+ new StampOptions { WithDate = withDate, LogoPath = logoPath, PoaNumber = poaNumber }, signTime);
+
+ ///
+ /// Создаёт штампованную копию «имя (со штампом).pdf» (существующая обновляется):
+ /// на выбранных страницах — ОТДЕЛЬНАЯ плашка на каждого подписанта
+ /// (колонками из правого нижнего угла).
+ ///
+ public static string CreateStampedCopy(
+ string pdfPath,
+ IReadOnlyList certificates,
+ StampOptions options,
+ DateTime signTime)
{
if (certificates.Count == 0)
throw new ArgumentException("Нет сертификатов для штампа.", nameof(certificates));
@@ -62,53 +93,122 @@ public static string CreateStampedCopy(
var outputPath = Path.Combine(directory, $"{stem} (со штампом).pdf");
using var document = PdfReader.Open(pdfPath, PdfDocumentOpenMode.Modify);
- var page = document.Pages[document.Pages.Count - 1];
- using (var gfx = XGraphics.FromPdfPage(page))
+ foreach (var pageIndex in ResolvePages(options, document.Pages.Count))
{
- DrawStamp(gfx, page, certificates, withDate, logoPath, signTime, poaNumber);
+ var page = document.Pages[pageIndex];
+ using var gfx = XGraphics.FromPdfPage(page);
+ DrawStampsOnPage(gfx, page, certificates, options, signTime);
}
document.Save(outputPath);
return outputPath;
}
- private static void DrawStamp(
+ /// Индексы страниц (0-based) по выбранному режиму; «1,3-5» — как в диалогах печати.
+ public static IReadOnlyList ResolvePages(StampOptions options, int pageCount)
+ {
+ switch (options.Pages)
+ {
+ case StampPages.First:
+ return new[] { 0 };
+ case StampPages.All:
+ return Enumerable.Range(0, pageCount).ToArray();
+ case StampPages.Custom:
+ var result = new SortedSet();
+ foreach (var part in (options.CustomPages ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
+ {
+ var range = part.Split('-', 2, StringSplitOptions.TrimEntries);
+ if (!int.TryParse(range[0], out var from))
+ throw new InvalidOperationException($"Непонятный номер страницы «{part}» — укажите, например: 1,3-5.");
+ var to = from;
+ if (range.Length == 2 && !int.TryParse(range[1], out to))
+ throw new InvalidOperationException($"Непонятный диапазон страниц «{part}».");
+ for (var p = Math.Max(1, from); p <= Math.Min(pageCount, to); p++)
+ result.Add(p - 1);
+ }
+
+ if (result.Count == 0)
+ throw new InvalidOperationException(
+ $"В документе {pageCount} стр., а указанные страницы «{options.CustomPages}» не найдены.");
+ return result.ToArray();
+ default:
+ return new[] { pageCount - 1 };
+ }
+ }
+
+ // Раскладка: плашки подписантов колонками из правого нижнего угла вверх,
+ // при нехватке высоты — следующая колонка левее.
+ private static void DrawStampsOnPage(
XGraphics gfx, PdfPage page, IReadOnlyList certificates,
- bool withDate, string? logoPath, DateTime signTime, string? poaNumber)
+ StampOptions options, DateTime signTime)
{
const double width = 250;
const double margin = 20;
+ const double gap = 8;
+
+ var x = page.Width.Point - width - margin;
+ var y = page.Height.Point - margin;
+
+ foreach (var certificate in certificates)
+ {
+ var height = ComputeStampHeight(certificate, options);
+ if (y - height < margin && y < page.Height.Point - margin)
+ {
+ // колонка заполнена — следующая левее
+ x -= width + gap;
+ y = page.Height.Point - margin;
+ }
+
+ y -= height;
+ DrawSingleStamp(gfx, new XRect(x, y, width, height), certificate, options, signTime);
+ y -= gap;
+ }
+ }
+
+ private static List StampLines(X509Certificate2 certificate, StampOptions options)
+ {
+ var lines = new List
+ {
+ $"Сертификат: {certificate.SerialNumber}",
+ $"Владелец: {CertificateProvider.GetSubjectName(certificate)}",
+ $"Действителен: с {certificate.NotBefore:dd.MM.yyyy} по {certificate.NotAfter:dd.MM.yyyy}",
+ };
+ if (!string.IsNullOrWhiteSpace(options.PoaNumber))
+ lines.Add($"Действует на основании МЧД № {options.PoaNumber}");
+ return lines;
+ }
+
+ private const double StampPad = 8;
+ private const double StampLineHeight = 10.5;
+ private const double StampTitleHeight = 24;
+
+ private static double ComputeStampHeight(X509Certificate2 certificate, StampOptions options)
+ {
+ var logoSize = options.LogoPath is not null ? 34.0 : 0.0;
+ var bodyLines = StampLines(certificate, options).Count + (options.WithDate ? 1 : 0);
+ return StampPad * 2 + Math.Max(StampTitleHeight, logoSize) + 4 + bodyLines * StampLineHeight;
+ }
+
+ private static void DrawSingleStamp(
+ XGraphics gfx, XRect rect, X509Certificate2 certificate, StampOptions options, DateTime signTime)
+ {
+ var width = rect.Width;
var blue = XColor.FromArgb(0x1B, 0x4F, 0x9C);
var pen = new XPen(blue, 1.2);
var titleFont = new XFont("stamp", 8, XFontStyleEx.Bold);
var textFont = new XFont("stamp", 7, XFontStyleEx.Regular);
- // Содержимое: блок на каждого подписанта, затем общие строки
- var lines = new List();
- for (var i = 0; i < certificates.Count; i++)
- {
- var c = certificates[i];
- if (certificates.Count > 1)
- lines.Add($"Подписант {i + 1}:");
- lines.Add($"Сертификат: {c.SerialNumber}");
- lines.Add($"Владелец: {CertificateProvider.GetSubjectName(c)}");
- lines.Add($"Действителен: с {c.NotBefore:dd.MM.yyyy} по {c.NotAfter:dd.MM.yyyy}");
- }
-
- if (!string.IsNullOrWhiteSpace(poaNumber))
- lines.Add($"Действует на основании МЧД № {poaNumber}");
- var dateLine = withDate ? $"Дата подписания: {signTime:dd.MM.yyyy HH:mm}" : null;
+ var lines = StampLines(certificate, options);
+ var dateLine = options.WithDate ? $"Дата подписания: {signTime:dd.MM.yyyy HH:mm}" : null;
+ var logoPath = options.LogoPath;
- const double pad = 8;
- const double lineHeight = 10.5;
- const double titleHeight = 24;
+ const double pad = StampPad;
+ const double lineHeight = StampLineHeight;
+ const double titleHeight = StampTitleHeight;
var logoSize = logoPath is not null ? 34.0 : 0.0;
- var bodyLines = lines.Count + (dateLine is null ? 0 : 1);
- var height = pad * 2 + Math.Max(titleHeight, logoSize) + 4 + bodyLines * lineHeight;
- var x = page.Width.Point - width - margin;
- var y = page.Height.Point - height - margin;
- var rect = new XRect(x, y, width, height);
+ var x = rect.X;
+ var y = rect.Y;
// Фон и рамка со скруглением
gfx.DrawRoundedRectangle(pen, XBrushes.White, rect, new XSize(8, 8));
diff --git a/src/SignService/Services/PowerOfAttorneyService.cs b/src/SignService/Services/PowerOfAttorneyService.cs
index 211bccf..3bbbf5b 100644
--- a/src/SignService/Services/PowerOfAttorneyService.cs
+++ b/src/SignService/Services/PowerOfAttorneyService.cs
@@ -5,6 +5,8 @@
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Xml.Linq;
+using System.Xml;
+using System.Collections.Generic;
namespace SignService.Services;
@@ -19,8 +21,10 @@ namespace SignService.Services;
public static class PowerOfAttorneyService
{
private const string SnilsOid = "1.2.643.100.3";
- private const string InnFlOid = "1.2.643.3.131.1.1";
- private const string InnLegacyOid = "1.2.643.100.4";
+ // ИНН: у физлица — 12 цифр; «00» + 10 цифр — прежняя запись ИНН организации.
+ private const string InnOid = "1.2.643.3.131.1.1";
+ // ИНН юридического лица (ИНН ЮЛ), 10 цифр — личным ИНН не является.
+ private const string InnLeOid = "1.2.643.100.4";
/// Сведения о доверенности из XML.
public sealed record PoaInfo(
@@ -33,7 +37,13 @@ public sealed record PoaInfo(
string PrincipalInn,
string RepresentativeName,
string RepresentativeInn,
- string RepresentativeSnils);
+ string RepresentativeSnils)
+ {
+ public IReadOnlyList Representatives { get; init; } = Array.Empty();
+ }
+
+ public sealed record Representative(string Name, string Inn, string Snils);
+ internal sealed record PoaPackage(PoaInfo Info, byte[] Xml, byte[] Signature, CheckResult Check);
public enum CheckState
{
@@ -48,7 +58,15 @@ public sealed record CheckResult(CheckState State, string Message);
/// Разбирает XML МЧД (EMCHD_1). Пространство имён допускается любое.
public static PoaInfo Parse(string xmlPath, string sigPath)
{
- var doc = XDocument.Load(xmlPath);
+ return ParseBytes(File.ReadAllBytes(xmlPath), xmlPath, sigPath);
+ }
+
+ private static PoaInfo ParseBytes(byte[] xml, string xmlPath, string sigPath)
+ {
+ using var input = new MemoryStream(xml, writable: false);
+ using var reader = XmlReader.Create(input, new XmlReaderSettings
+ { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 8 * 1024 * 1024 });
+ var doc = XDocument.Load(reader);
var root = doc.Root ?? throw new InvalidOperationException("Пустой XML-файл доверенности.");
if (root.Name.LocalName != "Доверенность")
throw new InvalidOperationException(
@@ -71,13 +89,12 @@ public static PoaInfo Parse(string xmlPath, string sigPath)
var org = Find(root, "СвРосОрг");
// Представитель — в блоке СвУпПред; у руководителя (ЛицоБезДов) похожие поля,
// поэтому ищем строго внутри СвУпПред.
- var pred = Find(root, "СвУпПред") is { } up ? Find(up, "СведФизЛ") : null;
- var fio = pred is not null ? Find(pred, "ФИО") : null;
-
- var repName = string.Join(" ", new[]
- {
- Attr(fio, "Фамилия"), Attr(fio, "Имя"), Attr(fio, "Отчество"),
- }.Where(s => s.Length > 0));
+ var representatives = root.Descendants().Where(e => e.Name.LocalName == "СвУпПред")
+ .SelectMany(e => e.Descendants().Where(p => p.Name.LocalName == "СведФизЛ"))
+ .Select(p => new Representative(string.Join(" ", new[] { Attr(Find(p, "ФИО"), "Фамилия"),
+ Attr(Find(p, "ФИО"), "Имя"), Attr(Find(p, "ФИО"), "Отчество") }.Where(s => s.Length > 0)),
+ Attr(p, "ИННФЛ"), Attr(p, "СНИЛС"))).Distinct().ToArray();
+ var representative = representatives.FirstOrDefault() ?? new Representative("", "", "");
return new PoaInfo(
xmlPath,
@@ -87,68 +104,119 @@ public static PoaInfo Parse(string xmlPath, string sigPath)
Date(Attr(svDov, "СрокДейст")),
Attr(org, "НаимОрг"),
Attr(org, "ИННЮЛ"),
- repName,
- Attr(pred, "ИННФЛ"),
- Attr(pred, "СНИЛС"));
+ representative.Name, representative.Inn, representative.Snils) { Representatives = representatives };
}
///
- /// Проверяет доверенность: подпись руководителя соответствует XML (по messageDigest,
- /// для ГОСТ — Стрибогом), срок действия не истёк, представитель в МЧД совпадает
+ /// Проверяет доверенность: подпись руководителя криптографически соответствует XML,
+ /// срок действия не истёк, представитель в МЧД совпадает
/// с выбранным сертификатом (по ИНН/СНИЛС из subject).
///
- public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertificate)
+ public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertificate, VerificationOptions? options = null)
{
- // 1. Подпись руководителя соответствует файлу МЧД
try
{
- var xml = File.ReadAllBytes(poa.XmlPath);
- var sig = File.ReadAllBytes(poa.SigPath);
- switch (CmsMerger.CheckAgainstDocument(sig, xml))
- {
- case CmsMerger.DocMatch.Mismatch:
- return new CheckResult(CheckState.Error,
- "Подпись руководителя НЕ соответствует файлу МЧД (подписан другой файл или другая версия).");
- case CmsMerger.DocMatch.Unknown:
- return new CheckResult(CheckState.Warning,
- "Не удалось проверить соответствие подписи руководителя файлу МЧД.");
- }
+ return Prepare(poa, signerCertificate, options).Check;
}
catch (Exception e)
{
return new CheckResult(CheckState.Error, "Не удалось разобрать подпись руководителя: " + e.Message);
}
- // 2. Срок действия
- if (poa.ValidTo is { } validTo && validTo.Date < DateTime.Today)
- return new CheckResult(CheckState.Error,
- $"Срок действия МЧД истёк {validTo:dd.MM.yyyy}.");
+ }
+
+ internal static PoaPackage Prepare(PoaInfo selected, X509Certificate2? signerCertificate,
+ VerificationOptions? options = null)
+ {
+ // Parse, verify and later copy exactly these bytes, never cached fields or a second read.
+ var xml = File.ReadAllBytes(selected.XmlPath);
+ var sig = File.ReadAllBytes(selected.SigPath);
+ var poa = ParseBytes(xml, selected.XmlPath, selected.SigPath);
+ options ??= new VerificationOptions();
+ var result = Check();
+ return new PoaPackage(poa, xml, sig, result);
- // 3. Представитель соответствует сертификату подписанта
- if (signerCertificate is not null)
+ CheckResult Check()
{
- var certInn = Digits(SubjectValue(signerCertificate, InnFlOid))
- is { Length: > 0 } innFl ? innFl : Digits(SubjectValue(signerCertificate, InnLegacyOid));
- var certSnils = Digits(SubjectValue(signerCertificate, SnilsOid));
- var poaInn = Digits(poa.RepresentativeInn);
- var poaSnils = Digits(poa.RepresentativeSnils);
-
- var innOk = certInn.Length > 0 && poaInn.Length > 0 && certInn == poaInn;
- var snilsOk = certSnils.Length > 0 && poaSnils.Length > 0 && certSnils == poaSnils;
+ var verification = SignatureVerifier.Verify(sig, xml, options);
+ if (!verification.CryptographicallyValid)
+ return new CheckResult(CheckState.Error, "Подпись руководителя не прошла полную криптографическую проверку МЧД. "
+ + verification.Container.Message + " " + string.Join("; ", verification.Signers.Select(s => s.Signature.Message + " " + s.Document.Message)));
+ if (string.IsNullOrWhiteSpace(poa.Number) || poa.IssueDate is null || poa.ValidTo is null
+ || poa.ValidTo < poa.IssueDate)
+ return new CheckResult(CheckState.Error, "В МЧД отсутствуют или некорректны номер и даты действия.");
+ if (poa.IssueDate.Value.Date > DateTime.Today)
+ return new CheckResult(CheckState.Error, "МЧД ещё не вступила в силу.");
+ if (poa.ValidTo is { } validTo && validTo.Date < DateTime.Today)
+ return new CheckResult(CheckState.Error,
+ $"Срок действия МЧД истёк {validTo:dd.MM.yyyy}.");
- if (!innOk && !snilsOk)
+ var warnings = new List();
+ // Представитель соответствует сертификату подписанта.
+ if (signerCertificate is not null)
{
- if (certInn.Length == 0 && certSnils.Length == 0)
- return new CheckResult(CheckState.Warning,
- "В сертификате нет ИНН/СНИЛС — соответствие представителя МЧД не проверено.");
- return new CheckResult(CheckState.Error,
- $"Представитель в МЧД ({poa.RepresentativeName}) не совпадает с владельцем сертификата по ИНН/СНИЛС.");
+ var certInn = PersonalInn(signerCertificate);
+ var certSnils = Digits(SubjectValue(signerCertificate, SnilsOid));
+ var match = poa.Representatives.FirstOrDefault(r =>
+ {
+ var inn = Digits(r.Inn); var snils = Digits(r.Snils);
+ return (certInn.Length == 0 || inn.Length == 0 || certInn == inn)
+ && (certSnils.Length == 0 || snils.Length == 0 || certSnils == snils)
+ && (certInn.Length > 0 && certInn == inn || certSnils.Length > 0 && certSnils == snils);
+ });
+ if (match is null)
+ {
+ if (certInn.Length == 0 && certSnils.Length == 0)
+ warnings.Add("В сертификате нет ИНН/СНИЛС — соответствие представителя МЧД не проверено.");
+ else
+ return new CheckResult(CheckState.Error,
+ $"Представитель в МЧД ({poa.RepresentativeName}) не совпадает с владельцем сертификата по ИНН/СНИЛС.");
+ }
+ else
+ poa = poa with { RepresentativeName = match.Name, RepresentativeInn = match.Inn, RepresentativeSnils = match.Snils };
}
- }
+ foreach (var signer in verification.Signers)
+ {
+ var trust = signer.CertificateTrust;
+ var revocation = signer.Revocation;
+ // Срок сертификата руководителя мог истечь уже после выдачи МЧД — сама МЧД
+ // при этом продолжает действовать. Без доверенной метки TSA время подписи
+ // не доказано: проверяем сертификат на дату выдачи и предупреждаем.
+ if (trust.State == VerificationState.Invalid && signer.NotAfter < options.ValidationTime.UtcDateTime
+ && !HasTrustedTimestamp(signer, options))
+ {
+ var issued = poa.IssueDate!.Value;
+ var moment = MomentOnIssueDay(issued, signer.NotBefore, signer.NotAfter);
+ if (moment is null)
+ return new CheckResult(CheckState.Error,
+ $"Сертификат руководителя не действовал на дату выдачи МЧД {issued:dd.MM.yyyy} "
+ + $"(срок сертификата {signer.NotBefore:dd.MM.yyyy} — {signer.NotAfter:dd.MM.yyyy}).");
+ var historical = SignatureVerifier.Verify(sig, xml, options with { ValidationTime = moment.Value })
+ .Signers.First(s => s.Number == signer.Number);
+ trust = historical.CertificateTrust;
+ revocation = historical.Revocation;
+ warnings.Add($"Сертификат руководителя истёк {signer.NotAfter:dd.MM.yyyy} — после выдачи МЧД "
+ + $"{issued:dd.MM.yyyy}; на дату выдачи он действовал. Время подписи МЧД не подтверждено "
+ + "меткой TSA — убедитесь, что МЧД зарегистрирована и не отозвана в реестре ФНС.");
+ }
- return new CheckResult(CheckState.Ok, "Доверенность проверена: подпись руководителя "
- + "соответствует МЧД, срок действия не истёк"
- + (signerCertificate is null ? "." : ", представитель совпадает с сертификатом."));
+ if (trust.State == VerificationState.Invalid || revocation.State == VerificationState.Invalid)
+ return new CheckResult(CheckState.Error, trust.Message + " " + revocation.Message);
+ if (trust.State != VerificationState.Valid || revocation.State != VerificationState.Valid)
+ warnings.Add("Доверие или отзыв сертификата руководителя не подтверждены.");
+ var principalInn = Digits(poa.PrincipalInn);
+ var cert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(signer.Certificate!);
+ var innValues = OrganizationInns(cert);
+ if (innValues.Count > 0 && principalInn.Length > 0 && innValues.Any(v => v != principalInn))
+ return new CheckResult(CheckState.Error, "ИНН организации в сертификате руководителя не совпадает с доверителем МЧД.");
+ if (innValues.Count == 0 || principalInn.Length == 0)
+ warnings.Add("Связь сертификата руководителя с организацией-доверителем не подтверждена по ИНН.");
+ }
+ if (signerCertificate is null) warnings.Add("Сертификат представителя не выбран.");
+ const string passed = "Подпись МЧД криптографически корректна, срок действия проверен. ";
+ return new CheckResult(warnings.Count > 0 ? CheckState.Warning : CheckState.Ok,
+ passed + string.Join(" ", warnings.Distinct()) + " Полномочия и отзыв самой МЧД в реестре проверяются отдельно.");
+ }
}
///
@@ -157,13 +225,82 @@ public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertific
///
public static void CopyNextToDocument(PoaInfo poa, string documentPath)
{
- var directory = Path.GetDirectoryName(Path.GetFullPath(documentPath)) ?? ".";
- foreach (var source in new[] { poa.XmlPath, poa.SigPath })
+ var package = Prepare(poa, null);
+ if (package.Check.State == CheckState.Error) throw new InvalidOperationException(package.Check.Message);
+ CopyPackage(package, documentPath);
+ }
+
+ internal static void CheckCopyTargets(PoaPackage package, string documentPath)
+ {
+ foreach (var (target, bytes) in CopyTargets(package, documentPath))
+ if (File.Exists(target) && !File.ReadAllBytes(target).AsSpan().SequenceEqual(bytes))
+ throw new IOException($"Рядом с документом уже есть другой файл МЧД «{Path.GetFileName(target)}». Выберите другой каталог.");
+ }
+
+ internal static void CopyPackage(PoaPackage package, string documentPath)
+ {
+ CheckCopyTargets(package, documentPath);
+ var created = new List();
+ try
{
- var target = Path.Combine(directory, Path.GetFileName(source));
- if (!File.Exists(target))
- File.Copy(source, target);
+ foreach (var (target, bytes) in CopyTargets(package, documentPath))
+ if (!File.Exists(target)) { AtomicFile.Write(target, bytes); created.Add(target); }
+ CheckCopyTargets(package, documentPath);
}
+ catch { foreach (var path in created) File.Delete(path); throw; }
+ }
+
+ private static IEnumerable<(string Path, byte[] Bytes)> CopyTargets(PoaPackage package, string documentPath)
+ {
+ var directory = Path.GetDirectoryName(Path.GetFullPath(documentPath))!;
+ var xmlTarget = Path.Combine(directory, Path.GetFileName(package.Info.XmlPath));
+ var sigTarget = Path.Combine(directory, Path.GetFileName(package.Info.SigPath));
+ if (string.Equals(xmlTarget, sigTarget, StringComparison.OrdinalIgnoreCase)
+ || new[] { xmlTarget, sigTarget }.Any(p => string.Equals(p, Path.GetFullPath(documentPath), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(p, Path.GetFullPath(documentPath + ".sig"), StringComparison.OrdinalIgnoreCase)))
+ throw new IOException("Имена файлов МЧД совпадают с документом или его подписью.");
+ yield return (xmlTarget, package.Xml);
+ yield return (sigTarget, package.Signature);
+ }
+
+ // Метка TSA, которой можно верить, сама задаёт момент проверки сертификата.
+ private static bool HasTrustedTimestamp(SignerVerification signer, VerificationOptions options) =>
+ options.CheckCertificateTrust && signer.Timestamp.State == VerificationState.Valid && signer.TimestampTime is not null;
+
+ ///
+ /// Момент дня выдачи МЧД, когда сертификат действовал; null — не действовал.
+ /// Дата МЧД — без времени и пояса, поэтому день берётся с запасом на часовые
+ /// пояса РФ (UTC+2…UTC+12): с 12:00 UTC предыдущего дня до 22:00 UTC этого.
+ ///
+ internal static DateTimeOffset? MomentOnIssueDay(DateTime issueDate, DateTime notBefore, DateTime notAfter)
+ {
+ var dayStart = new DateTimeOffset(DateTime.SpecifyKind(issueDate.Date, DateTimeKind.Utc)).AddHours(-12);
+ var dayEnd = dayStart.AddHours(34);
+ var validFrom = new DateTimeOffset(DateTime.SpecifyKind(notBefore, DateTimeKind.Utc));
+ var validTo = new DateTimeOffset(DateTime.SpecifyKind(notAfter, DateTimeKind.Utc));
+ var from = validFrom > dayStart ? validFrom : dayStart;
+ var to = validTo < dayEnd ? validTo : dayEnd;
+ return from <= to ? from + (to - from) / 2 : null;
+ }
+
+ ///
+ /// Личный ИНН владельца сертификата (ИНН ФЛ): только 12 цифр из 1.2.643.3.131.1.1.
+ /// ИНН организации (1.2.643.100.4 или «00» + ИНН ЮЛ) личным не считается.
+ ///
+ private static string PersonalInn(X509Certificate2 certificate)
+ {
+ var inn = Digits(SubjectValue(certificate, InnOid));
+ return inn.Length == 12 && !inn.StartsWith("00", StringComparison.Ordinal) ? inn : "";
+ }
+
+ /// ИНН организации в сертификате: 1.2.643.100.4 и прежняя запись «00» + ИНН ЮЛ.
+ private static List OrganizationInns(Org.BouncyCastle.X509.X509Certificate certificate)
+ {
+ var subject = certificate.SubjectDN;
+ return subject.GetValueList(new Org.BouncyCastle.Asn1.DerObjectIdentifier(InnLeOid)).Select(Digits)
+ .Concat(subject.GetValueList(new Org.BouncyCastle.Asn1.DerObjectIdentifier(InnOid)).Select(Digits)
+ .Where(v => v.Length == 12 && v.StartsWith("00", StringComparison.Ordinal)).Select(v => v[2..]))
+ .Where(v => v.Length > 0).Distinct().ToList();
}
/// Значение RDN субъекта сертификата по OID (СНИЛС/ИНН и т.п.).
diff --git a/src/SignService/Services/RevocationChecker.cs b/src/SignService/Services/RevocationChecker.cs
new file mode 100644
index 0000000..7e9619c
--- /dev/null
+++ b/src/SignService/Services/RevocationChecker.cs
@@ -0,0 +1,210 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Ocsp;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Ocsp;
+using Org.BouncyCastle.X509;
+
+namespace SignService.Services;
+
+/// Conservative CRL/OCSP validation. Missing, stale or unverifiable evidence is never "good".
+internal static class RevocationChecker
+{
+ private static readonly HttpClient Http = new(new HttpClientHandler { AllowAutoRedirect = false })
+ { Timeout = TimeSpan.FromSeconds(8) };
+ private const int MaxResponseBytes = 4 * 1024 * 1024;
+
+ public static VerificationCheck Check(IReadOnlyList chain, IReadOnlyList embeddedCrls,
+ VerificationOptions options, CancellationToken cancellationToken)
+ {
+ var crls = embeddedCrls.ToList();
+ foreach (var bytes in options.Crls)
+ try { crls.Add(new X509CrlParser().ReadCrl(bytes)); } catch (Exception) { }
+ var unknown = new List();
+ for (var i = 0; i + 1 < chain.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var cert = chain[i];
+ var issuer = chain[i + 1];
+ var evidence = new List();
+ foreach (var crl in crls) evidence.Add(CheckCrl(crl, cert, issuer, options.ValidationTime));
+ foreach (var response in options.OcspResponses)
+ evidence.Add(CheckOcsp(response, cert, issuer, options.ValidationTime));
+ if (options.AllowNetwork && !evidence.Any(e => e.State is VerificationState.Valid or VerificationState.Invalid))
+ {
+ foreach (var uri in OcspUrls(cert).Take(2))
+ {
+ var generator = new OcspReqGenerator();
+ generator.AddRequest(new CertificateID(new AlgorithmIdentifier(new DerObjectIdentifier("1.3.14.3.2.26")), issuer, cert.SerialNumber));
+ var response = Download(uri, generator.Generate().GetEncoded(), cancellationToken);
+ if (response is not null) evidence.Add(CheckOcsp(response, cert, issuer, options.ValidationTime));
+ }
+ foreach (var uri in CrlUrls(cert).Take(2))
+ {
+ var response = Download(uri, null, cancellationToken);
+ if (response is null) continue;
+ try { evidence.Add(CheckCrl(new X509CrlParser().ReadCrl(response), cert, issuer, options.ValidationTime)); }
+ catch (Exception) { }
+ }
+ }
+ // Any authenticated evidence of revocation takes precedence over a "good" response.
+ var revoked = evidence.FirstOrDefault(e => e.State == VerificationState.Invalid);
+ if (revoked is not null) return revoked;
+ if (!evidence.Any(e => e.State == VerificationState.Valid)) unknown.Add(cert.SubjectDN.ToString());
+ }
+ return unknown.Count == 0 ? VerificationCheck.Valid(chain.Count <= 1
+ ? "Цепочка состоит из точки доверия; для корневого сертификата отзыв не оценивается."
+ : "По свежим подписанным CRL/OCSP сертификаты цепочки не отозваны (корень — точка доверия).")
+ : VerificationCheck.Unknown("Нет свежих достоверных CRL/OCSP для: " + string.Join("; ", unknown)
+ + (options.AllowNetwork ? ". Запросы в сеть не дали подтверждения." : ". Сетевые запросы отключены."));
+ }
+
+ private static VerificationCheck CheckCrl(X509Crl crl, X509Certificate cert, X509Certificate issuer, DateTimeOffset at)
+ {
+ try
+ {
+ if (!crl.IssuerDN.Equivalent(issuer.SubjectDN)) return Unknown();
+ var usage = issuer.GetKeyUsage();
+ if (usage is not null && !usage[6]) return Unknown();
+ crl.Verify(issuer.GetPublicKey());
+ // Delta, indirect, partitioned or critical CRLs need additional processing. Do not infer "good" from them.
+ if (crl.GetExtensionValue(X509Extensions.DeltaCrlIndicator) is not null
+ || crl.GetExtensionValue(X509Extensions.IssuingDistributionPoint) is not null
+ || crl.GetCriticalExtensionOids() is { Count: > 0 }) return Unknown();
+ if (!Fresh(crl.ThisUpdate, crl.NextUpdate, at)) return Unknown();
+ var entry = crl.GetRevokedCertificate(cert.SerialNumber);
+ if (entry is not null)
+ {
+ if (entry.GetCriticalExtensionOids() is { Count: > 0 }) return Unknown();
+ if (entry.RevocationDate <= at.UtcDateTime)
+ return VerificationCheck.Invalid("Сертификат отозван по подписанному CRL: " + cert.SubjectDN);
+ }
+ return VerificationCheck.Valid("Свежий CRL издателя: сертификат не отозван.");
+ }
+ catch (Exception) { return Unknown(); }
+ }
+
+ private static VerificationCheck CheckOcsp(byte[] encoded, X509Certificate cert, X509Certificate issuer, DateTimeOffset at)
+ {
+ try
+ {
+ var response = new OcspResp(encoded);
+ if (response.Status != 0 || response.GetResponseObject() is not BasicOcspResp basic
+ || basic.GetCriticalExtensionOids() is { Count: > 0 }
+ || basic.ProducedAt > at.UtcDateTime.AddMinutes(5)) return Unknown();
+ var responders = basic.GetCerts().Append(issuer);
+ var authenticated = responders.Any(responder => AuthorizedResponder(basic, responder, issuer, at));
+ if (!authenticated) return Unknown();
+ foreach (var single in basic.Responses)
+ {
+ var id = single.GetCertID();
+ if (!id.SerialNumber.Equals(cert.SerialNumber) || !id.MatchesIssuer(issuer)
+ || single.GetCriticalExtensionOids() is { Count: > 0 }
+ || basic.ProducedAt < single.ThisUpdate.AddMinutes(-5)
+ || !Fresh(single.ThisUpdate, single.NextUpdate, at)) continue;
+ var status = single.GetCertStatus();
+ if (status is RevokedStatus revoked && revoked.RevocationTime <= at.UtcDateTime)
+ return VerificationCheck.Invalid("Сертификат отозван по подписанному OCSP: " + cert.SubjectDN);
+ if (status is null) return VerificationCheck.Valid("Свежий OCSP: статус good.");
+ }
+ return Unknown();
+ }
+ catch (Exception) { return Unknown(); }
+ }
+
+ private static bool AuthorizedResponder(BasicOcspResp response, X509Certificate responder, X509Certificate issuer, DateTimeOffset at)
+ {
+ try
+ {
+ if (!response.ResponderId.Equals(new RespID(responder.SubjectDN))
+ && !response.ResponderId.Equals(new RespID(responder.GetPublicKey()))) return false;
+ if (!responder.Equals(issuer))
+ {
+ if (!responder.IssuerDN.Equivalent(issuer.SubjectDN)) return false;
+ responder.Verify(issuer.GetPublicKey());
+ responder.CheckValidity(at.UtcDateTime);
+ if (responder.GetExtendedKeyUsage()?.Contains(KeyPurposeID.id_kp_OCSPSigning) != true) return false;
+ var usage = responder.GetKeyUsage();
+ if (usage is not null && !usage[0]) return false;
+ // Unrecognized critical extensions cannot be ignored on a delegated responder.
+ var allowed = new[] { X509Extensions.KeyUsage.Id, X509Extensions.ExtendedKeyUsage.Id,
+ X509Extensions.BasicConstraints.Id, OcspObjectIdentifiers.PkixOcspNocheck.Id };
+ if (responder.GetCriticalExtensionOids()?.Any(oid => !allowed.Contains(oid)) == true) return false;
+ }
+ return response.Verify(responder.GetPublicKey());
+ }
+ catch (Exception) { return false; }
+ }
+
+ private static bool Fresh(DateTime thisUpdate, DateTime? nextUpdate, DateTimeOffset at) =>
+ thisUpdate <= at.UtcDateTime.AddMinutes(5) && (nextUpdate is { } next
+ ? next >= thisUpdate && next >= at.UtcDateTime
+ : thisUpdate >= at.UtcDateTime.AddHours(-24));
+
+ private static VerificationCheck Unknown() => VerificationCheck.Unknown("Доказательство отзыва неприменимо, устарело или не прошло проверку.");
+
+ private static IEnumerable OcspUrls(X509Certificate cert)
+ {
+ var ext = cert.GetExtensionValue(X509Extensions.AuthorityInfoAccess);
+ if (ext is null) return Array.Empty();
+ try
+ {
+ return AuthorityInformationAccess.GetInstance(Asn1Object.FromByteArray(ext.GetOctets())).GetAccessDescriptions()
+ .Where(a => a.AccessMethod.Equals(AccessDescription.IdADOcsp)).Select(a => Url(a.AccessLocation)).OfType().ToArray();
+ }
+ catch (Exception) { return Array.Empty(); }
+ }
+
+ private static IEnumerable CrlUrls(X509Certificate cert)
+ {
+ var ext = cert.GetExtensionValue(X509Extensions.CrlDistributionPoints);
+ if (ext is null) return Array.Empty();
+ try
+ {
+ return CrlDistPoint.GetInstance(Asn1Object.FromByteArray(ext.GetOctets())).GetDistributionPoints()
+ .Where(d => d.DistributionPointName?.Type == DistributionPointName.FullName)
+ .SelectMany(d => GeneralNames.GetInstance(d.DistributionPointName.Name).GetNames())
+ .Select(Url).OfType().ToArray();
+ }
+ catch (Exception) { return Array.Empty(); }
+ }
+
+ private static Uri? Url(GeneralName name) => name.TagNo == GeneralName.UniformResourceIdentifier
+ && Uri.TryCreate(DerIA5String.GetInstance(name.Name).GetString(), UriKind.Absolute, out var uri)
+ && (uri.Scheme == "http" || uri.Scheme == "https") ? uri : null;
+
+ private static byte[]? Download(Uri uri, byte[]? ocspRequest, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var request = new HttpRequestMessage(ocspRequest is null ? HttpMethod.Get : HttpMethod.Post, uri);
+ if (ocspRequest is not null)
+ {
+ request.Content = new ByteArrayContent(ocspRequest);
+ request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/ocsp-request");
+ }
+ using var response = Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).GetAwaiter().GetResult();
+ if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > MaxResponseBytes) return null;
+ using var stream = response.Content.ReadAsStreamAsync(cancellationToken).GetAwaiter().GetResult();
+ using var body = new MemoryStream();
+ var buffer = new byte[8192];
+ int count;
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadline.CancelAfter(TimeSpan.FromSeconds(8));
+ while ((count = stream.ReadAsync(buffer, deadline.Token).AsTask().GetAwaiter().GetResult()) != 0)
+ {
+ if (body.Length + count > MaxResponseBytes) return null;
+ body.Write(buffer, 0, count);
+ }
+ return body.ToArray();
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch (Exception) { return null; }
+ }
+}
diff --git a/src/SignService/Services/SignatureVerification.cs b/src/SignService/Services/SignatureVerification.cs
new file mode 100644
index 0000000..78363df
--- /dev/null
+++ b/src/SignService/Services/SignatureVerification.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace SignService.Services;
+
+public enum VerificationState { Valid, Invalid, Unknown, NotChecked, NotPresent }
+
+public sealed record VerificationCheck(VerificationState State, string Message)
+{
+ public static VerificationCheck Valid(string message) => new(VerificationState.Valid, message);
+ public static VerificationCheck Invalid(string message) => new(VerificationState.Invalid, message);
+ public static VerificationCheck Unknown(string message) => new(VerificationState.Unknown, message);
+ public static VerificationCheck NotChecked(string message) => new(VerificationState.NotChecked, message);
+}
+
+public sealed record VerificationOptions
+{
+ public bool CheckCertificateTrust { get; init; } = true;
+ public bool CheckRevocation { get; init; } = true;
+ public bool AllowNetwork { get; init; }
+ public bool UseSystemTrustStore { get; init; } = true;
+ public DateTimeOffset ValidationTime { get; init; } = DateTimeOffset.UtcNow;
+ public IReadOnlyList TrustedRoots { get; init; } = Array.Empty();
+ public IReadOnlyList ExtraCertificates { get; init; } = Array.Empty();
+ public IReadOnlyList Crls { get; init; } = Array.Empty();
+ public IReadOnlyList OcspResponses { get; init; } = Array.Empty();
+
+ internal static VerificationOptions CryptographyOnly => new()
+ {
+ CheckCertificateTrust = false, CheckRevocation = false, UseSystemTrustStore = false,
+ };
+}
+
+public sealed record SignerVerification(
+ int Number, string Name, string Issuer, string SerialNumber, string Thumbprint,
+ string DigestAlgorithm, DateTime NotBefore, DateTime NotAfter, DateTimeOffset? SigningTime,
+ VerificationCheck Document, VerificationCheck Signature, VerificationCheck CertificateBinding,
+ VerificationCheck CertificateTrust, VerificationCheck Revocation, VerificationCheck Timestamp,
+ DateTimeOffset? TimestampTime, byte[]? Certificate)
+{
+ public bool CryptographicallyValid => Signature.State == VerificationState.Valid
+ && Document.State == VerificationState.Valid
+ && CertificateBinding.State is VerificationState.Valid or VerificationState.NotPresent;
+}
+
+public sealed record SignatureVerificationResult(
+ bool Attached, VerificationCheck Container, IReadOnlyList Signers)
+{
+ public bool CryptographicallyValid => Container.State == VerificationState.Valid
+ && Signers.Count > 0 && Signers.All(s => s.CryptographicallyValid);
+
+ public string ToReport(string signatureName, string? documentName)
+ {
+ var text = new StringBuilder();
+ text.AppendLine("ПРОВЕРКА ЭЛЕКТРОННОЙ ПОДПИСИ");
+ text.AppendLine("Подпись: " + signatureName);
+ text.AppendLine("Документ: " + (documentName ?? (Attached ? "вложен в контейнер" : "не выбран")));
+ text.AppendLine("Контейнер: " + Container.Message);
+ text.AppendLine($"Подписантов: {Signers.Count}");
+ foreach (var signer in Signers)
+ {
+ text.AppendLine();
+ text.AppendLine($"{signer.Number}. {signer.Name}");
+ text.AppendLine(" Издатель: " + signer.Issuer);
+ text.AppendLine(" Серийный номер: " + signer.SerialNumber);
+ text.AppendLine(" Отпечаток SHA-256: " + signer.Thumbprint);
+ text.AppendLine($" Сертификат: {signer.NotBefore:dd.MM.yyyy} — {signer.NotAfter:dd.MM.yyyy}");
+ text.AppendLine(" Алгоритм хеша: " + signer.DigestAlgorithm);
+ if (signer.SigningTime is { } time)
+ text.AppendLine($" Указанное подписантом время: {time.ToLocalTime():dd.MM.yyyy HH:mm:ss} (без подтверждения TSA)");
+ Line("Документ", signer.Document);
+ Line("Подпись", signer.Signature);
+ Line("Привязка сертификата", signer.CertificateBinding);
+ Line("Цепочка доверия", signer.CertificateTrust);
+ Line("Отзыв сертификатов", signer.Revocation);
+ Line("Метка времени", signer.Timestamp);
+ if (signer.TimestampTime is { } timestamp)
+ text.AppendLine($" Время TSA: {timestamp.ToLocalTime():dd.MM.yyyy HH:mm:ss}");
+ }
+ text.AppendLine();
+ text.AppendLine(CryptographicallyValid
+ ? "Криптографическая проверка: все подписи корректны и соответствуют документу."
+ : "Криптографическая проверка: есть ошибки или непроверенные подписи.");
+ text.AppendLine("Доверие, отзыв и метки времени указаны отдельно. «Не проверено» не означает «действительно».");
+ return text.ToString();
+
+ void Line(string label, VerificationCheck check) => text.AppendLine($" {label}: [{Label(check.State)}] {check.Message}");
+ }
+
+ public static string Label(VerificationState state) => state switch
+ {
+ VerificationState.Valid => "OK", VerificationState.Invalid => "ОШИБКА",
+ VerificationState.Unknown => "НЕ ОПРЕДЕЛЕНО", VerificationState.NotPresent => "ОТСУТСТВУЕТ",
+ _ => "НЕ ПРОВЕРЕНО",
+ };
+}
diff --git a/src/SignService/Services/SignatureVerifier.cs b/src/SignService/Services/SignatureVerifier.cs
new file mode 100644
index 0000000..4c7f384
--- /dev/null
+++ b/src/SignService/Services/SignatureVerifier.cs
@@ -0,0 +1,341 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Org.BouncyCastle.Asn1;
+using Org.BouncyCastle.Asn1.Cms;
+using Org.BouncyCastle.Asn1.Ess;
+using Org.BouncyCastle.Asn1.Pkcs;
+using Org.BouncyCastle.Asn1.X509;
+using Org.BouncyCastle.Cms;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.Tsp;
+using Org.BouncyCastle.X509;
+using CmsAttribute = Org.BouncyCastle.Asn1.Cms.Attribute;
+
+namespace SignService.Services;
+
+///
+/// Проверка каждого подписанта и отдельные результаты для документа, подписи,
+/// сертификата, доверия, отзыва и TSA. Все алгоритмы, включая ГОСТ, выполняются
+/// управляемой библиотекой: криптопровайдер и закрытые ключи для проверки не нужны.
+///
+public static class SignatureVerifier
+{
+ public static Task VerifyAsync(byte[] signature, byte[]? document = null,
+ VerificationOptions? options = null, CancellationToken cancellationToken = default) =>
+ Task.Run(() => Verify(signature, document, options, cancellationToken), cancellationToken);
+
+ public static SignatureVerificationResult Verify(byte[] signature, byte[]? document = null,
+ VerificationOptions? options = null, CancellationToken cancellationToken = default) =>
+ VerifyCore(signature, document, null, options, cancellationToken);
+
+ ///
+ /// Проверка с общим кешем хешей документа: при проверке нескольких подписей
+ /// одного документа (объединение) он хешируется один раз на алгоритм.
+ ///
+ internal static SignatureVerificationResult VerifyWithDigests(byte[] signature, DocumentDigests document,
+ VerificationOptions? options = null, CancellationToken cancellationToken = default) =>
+ VerifyCore(signature, document.Document, document, options, cancellationToken);
+
+ private static SignatureVerificationResult VerifyCore(byte[] signature, byte[]? document, DocumentDigests? digests,
+ VerificationOptions? options, CancellationToken cancellationToken)
+ {
+ options ??= new VerificationOptions();
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var normalized = CmsMerger.Normalize(signature);
+ var original = new CmsSignedData(normalized);
+ var attached = original.SignedContent is not null;
+ var embedded = attached ? CmsMerger.ExtractContent(normalized) : null;
+ var content = document ?? embedded;
+ if (content is not null && (digests is null || !ReferenceEquals(digests.Document, content)))
+ digests = new DocumentDigests(content);
+ var signers = original.GetSignerInfos().GetSigners().ToList();
+ var verifiable = digests is null ? signers : VerifiableSigners(original, normalized, signers, digests);
+ var certificates = original.GetCertificates().EnumerateMatches(null).ToList();
+ var crls = original.GetCrls().EnumerateMatches(null).ToList();
+ var results = new List();
+ foreach (var signer in verifiable)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var matches = original.GetCertificates().EnumerateMatches(signer.SignerID).ToList();
+ results.Add(VerifySigner(signer, matches, certificates, crls, digests,
+ embedded is not null && document is not null && !embedded.AsSpan().SequenceEqual(document),
+ results.Count + 1, options, cancellationToken));
+ }
+ return new SignatureVerificationResult(attached,
+ results.Count == 0 ? VerificationCheck.Invalid("В контейнере нет подписантов.")
+ : VerificationCheck.Valid(attached ? "CMS/PKCS#7, прикреплённая подпись." : "CMS/PKCS#7, откреплённая подпись."),
+ results);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e)
+ {
+ return new SignatureVerificationResult(false,
+ VerificationCheck.Invalid("Файл подписи повреждён или не является CMS/PKCS#7: " + e.Message),
+ Array.Empty());
+ }
+ }
+
+ // Подписанты с подписанными атрибутами проверяются по заранее вычисленному хешу
+ // документа (BouncyCastle не хеширует документ заново для каждого подписанта).
+ // Без signedAttrs (голый PKCS#7) подпись считается над самим содержимым —
+ // такие подписанты проверяются по документу.
+ private static List VerifiableSigners(CmsSignedData original, byte[] normalized,
+ List signers, DocumentDigests digests)
+ {
+ var hashes = new Dictionary();
+ foreach (var signer in signers)
+ hashes[signer.DigestAlgorithmID.Algorithm.Id] = digests.Get(signer.DigestAlgorithmID.Algorithm.Id) ?? Array.Empty();
+ var byDigest = new CmsSignedData(hashes, normalized).GetSignerInfos().GetSigners().ToList();
+ List? byContent = null;
+ var result = new List(signers.Count);
+ for (var i = 0; i < signers.Count; i++)
+ {
+ if (signers[i].SignedAttributes is not null && hashes[signers[i].DigestAlgorithmID.Algorithm.Id].Length > 0)
+ {
+ result.Add(byDigest[i]);
+ continue;
+ }
+
+ byContent ??= new CmsSignedData(new CmsProcessableByteArray(original.SignedContentType, digests.Document),
+ normalized).GetSignerInfos().GetSigners().ToList();
+ result.Add(byContent[i]);
+ }
+
+ return result;
+ }
+
+ private static SignerVerification VerifySigner(SignerInformation signer, List matches,
+ List certificates, List crls, DocumentDigests? document, bool attachedMismatch,
+ int number, VerificationOptions options, CancellationToken cancellationToken)
+ {
+ var cert = matches.Count == 1 ? matches[0] : null;
+ var documentCheck = document is null
+ ? VerificationCheck.NotChecked("Для откреплённой подписи выберите исходный документ.")
+ : CheckDocument(signer, document);
+ if (attachedMismatch)
+ documentCheck = VerificationCheck.Invalid("Вложенный документ отличается от выбранного файла.");
+ var binding = cert is null ? VerificationCheck.Unknown("Сертификат подписанта отсутствует или неоднозначен.")
+ : CheckCertificateBinding(signer, cert);
+ var signatureCheck = VerificationCheck.Unknown("Сертификат подписанта отсутствует или неоднозначен.");
+ if (cert is not null && document is not null && documentCheck.State == VerificationState.Unknown)
+ signatureCheck = VerificationCheck.Unknown("Проверка подписи невозможна: " + documentCheck.Message);
+ else if (cert is not null && document is not null)
+ {
+ try
+ {
+ signatureCheck = signer.Verify(cert.GetPublicKey())
+ ? VerificationCheck.Valid("Значение подписи проверено открытым ключом сертификата.")
+ : VerificationCheck.Invalid("Значение подписи неверно: подпись повреждена или подделана.");
+ }
+ catch (Exception e) when (UnsupportedAlgorithm(e)) { signatureCheck = VerificationCheck.Unknown("Алгоритм не поддерживается: " + e.Message); }
+ // Хеш документа не совпал — подпись относится к другому содержимому.
+ catch (Exception) when (documentCheck.State == VerificationState.Invalid)
+ {
+ signatureCheck = VerificationCheck.Invalid("Подпись не соответствует документу: " + documentCheck.Message);
+ }
+ // Иное исключение библиотеки (необычные параметры ключа, кодировка) — не доказательство
+ // подделки: «не определено», чтобы законный соподписант не был исключён из объединения.
+ catch (Exception e) { signatureCheck = VerificationCheck.Unknown("Подпись не удалось проверить: " + e.Message); }
+ }
+ else if (document is null)
+ signatureCheck = VerificationCheck.NotChecked("Нет исходного документа для полной проверки подписи.");
+
+ var timestamp = VerifyTimestamp(signer, options, cancellationToken);
+ // signing-time заявлен самим подписантом. Исторический момент допустим только
+ // при криптографически корректной метке от доверенной TSA.
+ var at = timestamp.Trusted && timestamp.Time is { } stamped ? stamped : options.ValidationTime;
+ var certificate = cert is null ? new CertificateValidation(
+ VerificationCheck.Unknown("Нет сертификата подписанта."), VerificationCheck.Unknown("Нет сертификата подписанта."))
+ : CertificateValidator.Validate(cert, certificates, crls, options with { ValidationTime = at }, cancellationToken);
+ return new SignerVerification(number, cert?.SubjectDN.ToString() ?? "Неизвестный подписант",
+ cert?.IssuerDN.ToString() ?? "", cert?.SerialNumber.ToString(16).ToUpperInvariant() ?? "",
+ cert is null ? "" : Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.GetEncoded())),
+ AlgorithmName(signer.DigestAlgorithmID.Algorithm.Id), cert?.NotBefore ?? default, cert?.NotAfter ?? default,
+ SigningTime(signer), documentCheck, signatureCheck, binding, certificate.Trust,
+ certificate.Revocation, timestamp.Check, timestamp.Time, cert?.GetEncoded());
+ }
+
+ private static VerificationCheck CheckDocument(SignerInformation signer, DocumentDigests document)
+ {
+ try
+ {
+ var attr = signer.SignedAttributes?[PkcsObjectIdentifiers.Pkcs9AtMessageDigest];
+ if (attr is null)
+ return VerificationCheck.Valid("Документ проверяется непосредственно значением подписи (без signedAttrs).");
+ if (attr.AttrValues.Count != 1)
+ return VerificationCheck.Invalid("Некорректный атрибут messageDigest.");
+ var digest = Asn1OctetString.GetInstance(attr.AttrValues[0]).GetOctets();
+ var actual = document.Get(signer.DigestAlgorithmID.Algorithm.Id);
+ if (actual is null)
+ return VerificationCheck.Unknown("Алгоритм хеша не поддерживается: " + signer.DigestAlgorithmID.Algorithm.Id);
+ return actual.AsSpan().SequenceEqual(digest)
+ ? VerificationCheck.Valid("Хеш соответствует текущему содержимому документа.")
+ : VerificationCheck.Invalid("Хеш не совпал: подпись относится к другому файлу или его прежней версии.");
+ }
+ catch (Exception e) { return VerificationCheck.Unknown("Не удалось проверить messageDigest: " + e.Message); }
+ }
+
+ internal static VerificationCheck CheckCertificateBinding(SignerInformation signer, X509Certificate certificate)
+ {
+ try
+ {
+ var oidV2 = new DerObjectIdentifier(CadesAttributes.SigningCertificateV2Oid);
+ var oidV1 = new DerObjectIdentifier("1.2.840.113549.1.9.16.2.12");
+ var v2 = signer.SignedAttributes?[oidV2];
+ var v1 = signer.SignedAttributes?[oidV1];
+ if (v1 is null && v2 is null)
+ return new VerificationCheck(VerificationState.NotPresent, "Обычная CMS-подпись: signing-certificate отсутствует.");
+ if (v2 is not null)
+ {
+ if (signer.SignedAttributes!.GetAll(oidV2).Count != 1 || v2.AttrValues.Count != 1)
+ return VerificationCheck.Invalid("Неоднозначный signing-certificate-v2.");
+ var ids = SigningCertificateV2.GetInstance(v2.AttrValues[0]).GetCerts();
+ if (ids.Length == 0 || !DigestUtilities.CalculateDigest(ids[0].HashAlgorithm.Algorithm.Id,
+ certificate.GetEncoded()).AsSpan().SequenceEqual(ids[0].GetCertHash())
+ || !IssuerMatches(ids[0].IssuerSerial, certificate))
+ return VerificationCheck.Invalid("signing-certificate-v2 не соответствует сертификату подписанта.");
+ }
+ if (v1 is not null)
+ {
+ if (signer.SignedAttributes!.GetAll(oidV1).Count != 1 || v1.AttrValues.Count != 1)
+ return VerificationCheck.Invalid("Неоднозначный signing-certificate.");
+ var ids = SigningCertificate.GetInstance(v1.AttrValues[0]).GetCerts();
+ if (ids.Length == 0 || !DigestUtilities.CalculateDigest("SHA-1", certificate.GetEncoded())
+ .AsSpan().SequenceEqual(ids[0].GetCertHash()) || !IssuerMatches(ids[0].IssuerSerial, certificate))
+ return VerificationCheck.Invalid("signing-certificate не соответствует сертификату подписанта.");
+ }
+ return VerificationCheck.Valid("Хеш и идентификатор сертификата в подписанных атрибутах совпали.");
+ }
+ catch (SecurityUtilityException e) { return VerificationCheck.Unknown("Алгоритм привязки сертификата не поддерживается: " + e.Message); }
+ catch (Exception e) { return VerificationCheck.Unknown("Не удалось проверить привязку сертификата: " + e.Message); }
+ }
+
+ private static bool IssuerMatches(IssuerSerial? issuer, X509Certificate certificate) => issuer is null
+ || (issuer.Serial.Value.Equals(certificate.SerialNumber) && issuer.Issuer.GetNames().Any(n =>
+ n.TagNo == GeneralName.DirectoryName && X509Name.GetInstance(n.Name).Equivalent(certificate.IssuerDN)));
+
+ internal sealed record TimestampVerification(VerificationCheck Check, DateTimeOffset? Time, bool Trusted);
+
+ private static TimestampVerification VerifyTimestamp(SignerInformation signer, VerificationOptions options,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var oid = new DerObjectIdentifier(TimestampClient.TimeStampTokenOid);
+ var attr = signer.UnsignedAttributes?[oid];
+ if (attr is null)
+ return new TimestampVerification(new VerificationCheck(VerificationState.NotPresent,
+ "Метки TSA нет. signing-time не подтверждает время создания подписи."), null, false);
+ if (signer.UnsignedAttributes!.GetAll(oid).Count != 1 || attr.AttrValues.Count != 1)
+ return new TimestampVerification(VerificationCheck.Invalid("Неоднозначный атрибут метки времени."), null, false);
+ return VerifyTimestampToken(attr.AttrValues[0].GetEncoded(), signer.GetSignature(), options, cancellationToken);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e) { return new TimestampVerification(VerificationCheck.Invalid("Некорректная метка времени: " + e.Message), null, false); }
+ }
+
+ internal static TimestampVerification VerifyTimestampToken(byte[] encodedToken, byte[] signatureValue,
+ VerificationOptions options, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var token = new TimeStampToken(Org.BouncyCastle.Asn1.Cms.ContentInfo.GetInstance(Asn1Object.FromByteArray(encodedToken)));
+ var info = token.TimeStampInfo;
+ if (!DigestUtilities.CalculateDigest(info.MessageImprintAlgOid, signatureValue).AsSpan()
+ .SequenceEqual(info.GetMessageImprintDigest()))
+ return new TimestampVerification(VerificationCheck.Invalid("Метка TSA относится к другому значению подписи."), null, false);
+ var matches = token.GetCertificates().EnumerateMatches(token.SignerID).ToList();
+ if (matches.Count != 1)
+ return new TimestampVerification(VerificationCheck.Invalid("Нет однозначного сертификата TSA."), null, false);
+ token.Validate(matches[0]); // подпись, ESSCertID, EKU timeStamping и срок сертификата
+ var time = new DateTimeOffset(DateTime.SpecifyKind(info.GenTime, DateTimeKind.Utc));
+ if (time > options.ValidationTime.AddMinutes(5))
+ return new TimestampVerification(VerificationCheck.Invalid("Метка TSA указывает время в будущем."), time, false);
+ var validation = CertificateValidator.Validate(matches[0], token.GetCertificates().EnumerateMatches(null).ToList(),
+ token.GetCrls().EnumerateMatches(null).ToList(), options with { ValidationTime = time }, cancellationToken);
+ if (!options.CheckCertificateTrust)
+ return new TimestampVerification(VerificationCheck.Valid("Подпись и хеш TSA корректны; доверие TSA не проверялось."), time, false);
+ var trusted = validation.Trust.State == VerificationState.Valid
+ && (!options.CheckRevocation || validation.Revocation.State == VerificationState.Valid);
+ return new TimestampVerification(trusted
+ ? VerificationCheck.Valid("Подпись, хеш, цепочка доверия и требуемые проверки TSA пройдены.")
+ : new VerificationCheck(validation.Trust.State == VerificationState.Invalid || validation.Revocation.State == VerificationState.Invalid
+ ? VerificationState.Invalid : VerificationState.Unknown,
+ "Подпись и хеш TSA корректны. " + validation.Trust.Message + " " + validation.Revocation.Message), time, trusted);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception e) { return new TimestampVerification(VerificationCheck.Invalid("Метка TSA не прошла проверку: " + e.Message), null, false); }
+ }
+
+ private static DateTimeOffset? SigningTime(SignerInformation signer)
+ {
+ try
+ {
+ var attr = signer.SignedAttributes?[PkcsObjectIdentifiers.Pkcs9AtSigningTime];
+ return attr is null ? null : new DateTimeOffset(DateTime.SpecifyKind(
+ Org.BouncyCastle.Asn1.Cms.Time.GetInstance(attr.AttrValues[0]).ToDateTime(), DateTimeKind.Utc));
+ }
+ catch { return null; }
+ }
+
+ public static string AlgorithmName(string oid) => oid switch
+ {
+ "1.2.643.7.1.1.2.2" => "ГОСТ Р 34.11-2012-256",
+ "1.2.643.7.1.1.2.3" => "ГОСТ Р 34.11-2012-512",
+ "1.2.643.2.2.9" => "ГОСТ Р 34.11-94",
+ "2.16.840.1.101.3.4.2.1" => "SHA-256", "2.16.840.1.101.3.4.2.2" => "SHA-384",
+ "2.16.840.1.101.3.4.2.3" => "SHA-512", "1.3.14.3.2.26" => "SHA-1", _ => oid,
+ };
+
+ private static bool UnsupportedAlgorithm(Exception exception)
+ {
+ for (Exception? current = exception; current is not null; current = current.InnerException)
+ if (current is SecurityUtilityException or NotSupportedException) return true;
+ return false;
+ }
+}
+
+///
+/// Хеши одного документа по OID алгоритма: каждый алгоритм вычисляется один раз,
+/// сколько бы подписантов его ни использовали.
+///
+internal sealed class DocumentDigests
+{
+ private readonly Dictionary _digests = new();
+
+ public DocumentDigests(byte[] document) => Document = document;
+
+ public byte[] Document { get; }
+
+ /// Сколько раз документ хешировался (для тестов).
+ public int Computations { get; private set; }
+
+ /// Хеш документа; null — алгоритм не поддерживается.
+ public byte[]? Get(string digestOid)
+ {
+ lock (_digests)
+ {
+ if (!_digests.TryGetValue(digestOid, out var digest))
+ {
+ try
+ {
+ digest = DigestUtilities.CalculateDigest(digestOid, Document);
+ Computations++;
+ }
+ catch (SecurityUtilityException)
+ {
+ digest = null;
+ }
+
+ _digests[digestOid] = digest;
+ }
+
+ return digest;
+ }
+ }
+}
diff --git a/src/SignService/Services/TimestampClient.cs b/src/SignService/Services/TimestampClient.cs
index 1b6ff0e..58d11b9 100644
--- a/src/SignService/Services/TimestampClient.cs
+++ b/src/SignService/Services/TimestampClient.cs
@@ -4,6 +4,10 @@
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
+using System.IO;
+using Org.BouncyCastle.Tsp;
+using Org.BouncyCastle.Math;
+using Org.BouncyCastle.Security;
namespace SignService.Services;
@@ -31,6 +35,8 @@ public class TimestampClient
public virtual async Task RequestTokenAsync(
byte[] signatureValue, bool gost, string tsaUrl, CancellationToken cancellationToken = default)
{
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadline.CancelAfter(TimeSpan.FromSeconds(30));
byte[] hash;
string hashOid;
if (gost)
@@ -49,20 +55,20 @@ public virtual async Task RequestTokenAsync(
// старший байт без знакового бита — некоторые TSA не принимают отрицательный nonce
nonce[0] &= 0x7F;
- var request = System.Security.Cryptography.Pkcs.Rfc3161TimestampRequest.CreateFromHash(
- hash,
- new Oid(hashOid),
- nonce: nonce,
- requestSignerCertificates: true);
+ var generator = new TimeStampRequestGenerator();
+ generator.SetCertReq(true);
+ var request = generator.Generate(hashOid, hash, new BigInteger(1, nonce));
- using var content = new ByteArrayContent(request.Encode());
+ using var content = new ByteArrayContent(request.GetEncoded());
content.Headers.ContentType = new MediaTypeHeaderValue("application/timestamp-query");
HttpResponseMessage response;
try
{
- response = await Http.PostAsync(tsaUrl, content, cancellationToken);
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tsaUrl) { Content = content };
+ response = await Http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, deadline.Token);
}
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
throw new InvalidOperationException(
@@ -75,17 +81,39 @@ public virtual async Task RequestTokenAsync(
throw new InvalidOperationException(
$"Служба штампов времени вернула ошибку HTTP {(int)response.StatusCode} ({tsaUrl}).");
- var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ const int maxBytes = 4 * 1024 * 1024;
+ if (response.Content.Headers.ContentLength > maxBytes)
+ throw new InvalidOperationException("Ответ TSA превышает допустимый размер.");
+ await using var input = await response.Content.ReadAsStreamAsync(deadline.Token);
+ using var output = new MemoryStream();
+ var buffer = new byte[8192];
+ int count;
+ while ((count = await input.ReadAsync(buffer, deadline.Token)) != 0)
+ {
+ if (output.Length + count > maxBytes) throw new InvalidOperationException("Ответ TSA превышает допустимый размер.");
+ output.Write(buffer, 0, count);
+ }
try
{
- var token = request.ProcessResponse(body, out _);
- return token.AsSignedCms().Encode();
+ return ValidateResponse(output.ToArray(), request, signatureValue);
}
- catch (CryptographicException e)
+ catch (Exception e) when (e is not OperationCanceledException)
{
throw new InvalidOperationException(
"Служба штампов времени вернула некорректный ответ: " + e.Message, e);
}
}
}
+
+ internal static byte[] ValidateResponse(byte[] responseBytes, TimeStampRequest request, byte[] signatureValue)
+ {
+ var response = new TimeStampResponse(responseBytes);
+ response.Validate(request); // status, nonce, policy, digest algorithm and message imprint
+ var token = response.TimeStampToken ?? throw new CryptographicException("TSA не вернула метку времени.");
+ var encoded = token.GetEncoded();
+ var check = SignatureVerifier.VerifyTimestampToken(encoded, signatureValue, VerificationOptions.CryptographyOnly);
+ if (check.Check.State != VerificationState.Valid) throw new CryptographicException(check.Check.Message);
+ // This proves token integrity. TSA trust/revocation is a separate result in the verification report.
+ return encoded;
+ }
}
diff --git a/src/SignService/Services/UpdateService.cs b/src/SignService/Services/UpdateService.cs
index dee39f4..0e96094 100644
--- a/src/SignService/Services/UpdateService.cs
+++ b/src/SignService/Services/UpdateService.cs
@@ -5,6 +5,7 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
+using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -13,8 +14,9 @@ namespace SignService.Services;
///
/// Автообновление программы через GitHub Releases: проверка последнего релиза,
-/// скачивание нового SignService.exe и самозамена (Windows) через командный
-/// скрипт, который дожидается выхода программы, подменяет exe и запускает его.
+/// скачивание нового SignService.exe и самозамена (Windows) через помощник
+/// PowerShell, который дожидается выхода программы и заменяет exe с резервной
+/// копией и откатом при неудаче.
///
public class UpdateService
{
@@ -104,7 +106,7 @@ public async Task DownloadAsync(UpdateInfo update, CancellationToken can
if (update.ExeDownloadUrl is null)
throw new InvalidOperationException("В релизе нет файла SignService.exe.");
- var tempPath = Path.Combine(Path.GetTempPath(), $"SignService-{update.Version}.exe");
+ var tempPath = Path.Combine(Path.GetTempPath(), $"SignService-{update.Version}-{Guid.NewGuid():N}.exe");
var bytes = await Http.GetByteArrayAsync(update.ExeDownloadUrl, cancellationToken);
if (bytes.Length < 1024 * 1024)
throw new InvalidOperationException("Скачанный файл подозрительно мал — обновление прервано.");
@@ -113,8 +115,9 @@ public async Task DownloadAsync(UpdateInfo update, CancellationToken can
}
///
- /// Запускает самозамену (только Windows): скрипт дожидается завершения программы,
- /// заменяет текущий exe скачанным и запускает новую версию. Вызывающий должен
+ /// Запускает самозамену (только Windows): помощник дожидается завершения программы,
+ /// заменяет текущий exe скачанным (старый сохраняется как .bak) и запускает новую
+ /// версию; если запуск не удался — возвращает прежний exe. Вызывающий должен
/// закрыть приложение сразу после вызова.
///
public void ApplyAndRestart(string downloadedExePath)
@@ -125,26 +128,124 @@ public void ApplyAndRestart(string downloadedExePath)
var currentExe = Environment.ProcessPath
?? throw new InvalidOperationException("Не удалось определить путь к текущему exe.");
+ if (!Path.GetFileName(currentExe).Equals("SignService.exe", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("Автообновление доступно при запуске SignService.exe. Скачайте релиз вручную.");
+ if (!File.Exists(downloadedExePath))
+ throw new FileNotFoundException("Файл обновления не найден.", downloadedExePath);
- var script = Path.Combine(Path.GetTempPath(), "signservice_update.cmd");
- // Ждём, пока exe освободится (программа закрывается), затем подменяем и запускаем.
- File.WriteAllText(script, $"""
- @echo off
- :wait
- timeout /t 1 /nobreak >nul
- del "{currentExe}" 2>nul
- if exist "{currentExe}" goto wait
- move /y "{downloadedExePath}" "{currentExe}" >nul
- start "" "{currentExe}"
- del "%~f0"
- """);
-
- Process.Start(new ProcessStartInfo
+ var script = BuildInstallScript(currentExe, downloadedExePath, Environment.ProcessId);
+
+ _ = Process.Start(new ProcessStartInfo
{
- FileName = "cmd.exe",
- Arguments = $"/c \"{script}\"",
+ FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
+ "WindowsPowerShell", "v1.0", "powershell.exe"),
+ Arguments = "-NoProfile -NonInteractive -EncodedCommand "
+ + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)),
CreateNoWindow = true,
UseShellExecute = false,
- });
+ }) ?? throw new InvalidOperationException("Не удалось запустить установщик обновления.");
+ }
+
+ // Пути передаются как данные JSON/base64, без интерполяции в команды оболочки.
+ // Замена повторяется до replaceTimeoutMs: exe может ещё держать завершающийся
+ // процесс или антивирус. Этот же помощник проверяется интеграционными тестами.
+ internal static string BuildInstallScript(string currentExe, string downloadedExePath,
+ int processId, bool restart = true, int replaceTimeoutMs = 30_000)
+ {
+ var config = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
+ {
+ current = Path.GetFullPath(currentExe),
+ downloaded = Path.GetFullPath(downloadedExePath),
+ parent = processId,
+ restart,
+ replaceTimeoutMs,
+ })));
+ return $$"""
+ $ErrorActionPreference = 'Stop'
+ $config = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{config}}')) | ConvertFrom-Json
+ $stage = $config.current + '.' + [Guid]::NewGuid().ToString('N') + '.new'
+ $backup = $config.current + '.' + [Guid]::NewGuid().ToString('N') + '.bak'
+ $log = $config.downloaded + '.update.log'
+ $replaced = $false
+ $parentExited = $config.parent -le 0
+ try {
+ if (-not [IO.File]::Exists($config.current)) { throw 'Current executable is missing' }
+ # Подготавливаем полную копию на том же диске до изменения текущего EXE.
+ [IO.File]::Copy($config.downloaded, $stage, $false)
+ if (([IO.FileInfo]::new($stage)).Length -ne ([IO.FileInfo]::new($config.downloaded)).Length) {
+ throw 'Incomplete update copy'
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ if (-not $parentExited) {
+ $parentProcess = Get-Process -Id $config.parent -ErrorAction SilentlyContinue
+ if ($null -eq $parentProcess) { $parentExited = $true }
+ else {
+ while (-not $parentProcess.HasExited -and [DateTime]::UtcNow -lt $deadline) {
+ Start-Sleep -Milliseconds 250
+ }
+ $parentExited = $parentProcess.HasExited
+ }
+ }
+ if (-not $parentExited) { throw 'Timed out waiting for SignService to exit' }
+ # Replace атомарен: при ошибке исходный EXE остаётся. Файл может быть ещё
+ # занят завершающимся процессом или антивирусом — повторяем до таймаута.
+ $replaceDeadline = [DateTime]::UtcNow.AddMilliseconds($config.replaceTimeoutMs)
+ while ($true) {
+ try {
+ if ([IO.File]::Exists($config.current)) {
+ [IO.File]::Replace($stage, $config.current, $backup)
+ }
+ else {
+ # Replace успел переименовать старый EXE в резервную копию.
+ [IO.File]::Move($stage, $config.current)
+ }
+ break
+ }
+ catch {
+ if ([DateTime]::UtcNow -ge $replaceDeadline) { throw }
+ # Неудачная попытка могла оставить копию прежнего EXE под именем резервной.
+ if ([IO.File]::Exists($backup) -and [IO.File]::Exists($config.current)) {
+ try { [IO.File]::Delete($backup) }
+ catch { $backup = $config.current + '.' + [Guid]::NewGuid().ToString('N') + '.bak' }
+ }
+ Start-Sleep -Milliseconds 250
+ }
+ }
+ $replaced = $true
+ if ($config.restart) {
+ [Diagnostics.Process]::Start($config.current) | Out-Null
+ }
+ [IO.File]::WriteAllText($log, 'Update installed. Backup: ' + $backup)
+ }
+ catch {
+ $failure = $_.Exception.Message
+ if ($replaced -and [IO.File]::Exists($backup)) {
+ try {
+ $failedUpdate = $stage + '.failed'
+ [IO.File]::Replace($backup, $config.current, $failedUpdate)
+ [IO.File]::Delete($failedUpdate)
+ }
+ catch { $failure += '; rollback failed: ' + $_.Exception.Message + '; backup: ' + $backup }
+ }
+ elseif (-not [IO.File]::Exists($config.current) -and [IO.File]::Exists($backup)) {
+ try { [IO.File]::Move($backup, $config.current) }
+ catch { $failure += '; restore failed: ' + $_.Exception.Message + '; backup: ' + $backup }
+ }
+ elseif ([IO.File]::Exists($backup)) {
+ # Замена не состоялась, прежний EXE на месте — лишняя копия не нужна.
+ try { [IO.File]::Delete($backup) } catch { }
+ }
+ try { [IO.File]::WriteAllText($log, $failure) } catch { }
+ if ($config.restart -and $parentExited -and [IO.File]::Exists($config.current)) {
+ try { [Diagnostics.Process]::Start($config.current) | Out-Null } catch { }
+ }
+ exit 1
+ }
+ finally {
+ if ([IO.File]::Exists($stage)) {
+ try { [IO.File]::Delete($stage) } catch { }
+ }
+ }
+ """;
}
}
diff --git a/src/SignService/SignService.csproj b/src/SignService/SignService.csproj
index 1719030..00745ab 100644
--- a/src/SignService/SignService.csproj
+++ b/src/SignService/SignService.csproj
@@ -10,7 +10,7 @@
true
SignService — подписание документов ЭЦП
SignService
- 1.0.0
+ 1.2.0
Зайнуллин Марат Илгамович
Зайнуллин Марат Илгамович
© 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com
@@ -26,6 +26,7 @@
+
diff --git a/src/SignService/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs
index 0d0ca81..4fa3536 100644
--- a/src/SignService/ViewModels/MainWindowViewModel.cs
+++ b/src/SignService/ViewModels/MainWindowViewModel.cs
@@ -13,15 +13,27 @@ public partial class MainWindowViewModel : ObservableObject
{
private readonly CertificateProvider _certificateProvider;
private readonly DocumentSigner _documentSigner;
- private readonly CertificateVault _vault = new();
+ private readonly CertificateVault _vault;
private readonly AppSettings _settings;
private bool _initializing;
+ // Отпечаток выбранного сертификата: список пересоздаёт элементы при обновлении,
+ // поэтому смену сертификата определяем по отпечатку, а не по экземпляру.
+ private string? _activeThumbprint;
+
public MainWindowViewModel(CertificateProvider certificateProvider, DocumentSigner documentSigner)
+ : this(certificateProvider, documentSigner, AppSettings.Load())
+ {
+ }
+
+ /// С явными настройками и папкой сохранённых сертификатов — для тестов.
+ public MainWindowViewModel(CertificateProvider certificateProvider, DocumentSigner documentSigner,
+ AppSettings settings, CertificateVault? vault = null)
{
_certificateProvider = certificateProvider;
_documentSigner = documentSigner;
- _settings = AppSettings.Load();
+ _settings = settings;
+ _vault = vault ?? new CertificateVault();
_initializing = true;
IsDetached = _settings.DetachedSignature;
@@ -37,10 +49,9 @@ public MainWindowViewModel(CertificateProvider certificateProvider, DocumentSign
Files.CollectionChanged += (_, _) => SignAllCommand.NotifyCanExecuteChanged();
RefreshCertificates();
- // Восстанавливаем ранее добавленную МЧД (с повторной проверкой).
- if (_settings.PoaXmlPath is { } poaXml && _settings.PoaSigPath is { } poaSig
- && System.IO.File.Exists(poaXml) && System.IO.File.Exists(poaSig))
- SetPoa(poaXml, poaSig, quiet: true);
+ // Восстанавливаем ранее добавленную МЧД: повторная проверка идёт в фоне.
+ if (_settings.PoaXmlPath is { } poaXml && _settings.PoaSigPath is { } poaSig)
+ _ = SetPoaAsync(poaXml, poaSig, restoring: true);
_ = CheckUpdatesOnStartAsync();
}
@@ -154,20 +165,25 @@ private async Task CheckUpdatesOnStartAsync()
[NotifyCanExecuteChangedFor(nameof(ExtractCommand))]
[NotifyCanExecuteChangedFor(nameof(MergeFilesCommand))]
[NotifyCanExecuteChangedFor(nameof(BuildContainerCommand))]
+ [NotifyCanExecuteChangedFor(nameof(VerifySignatureCommand))]
+ [NotifyCanExecuteChangedFor(nameof(SplitSignaturesCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RemoveSignerCommand))]
+ [NotifyCanExecuteChangedFor(nameof(AttachSignaturesCommand))]
+ [NotifyCanExecuteChangedFor(nameof(StampItemCommand))]
+ [NotifyCanExecuteChangedFor(nameof(ExtractItemCommand))]
+ [NotifyCanExecuteChangedFor(nameof(BuildContainerItemCommand))]
+ [NotifyCanExecuteChangedFor(nameof(SplitItemCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RemoveSignerItemCommand))]
private bool _isBusy;
[ObservableProperty]
private string _statusText = "Перетащите файлы в окно или добавьте их через «Обзор…»";
- /// Текст окна лога: все операции с отметкой времени.
+ /// Текст окна лога (всегда видимого): все операции с отметкой времени.
[ObservableProperty]
private string _logText = "";
- /// Показывать ли панель лога.
- [ObservableProperty]
- private bool _isLogVisible;
-
- // Каждая смена статуса попадает и в лог — плюс подробные строки по файлам.
+ // Каждая смена статуса попадает в лог — статусная строка в UI не дублируется.
partial void OnStatusTextChanged(string value) => Log(value);
private void Log(string message)
@@ -177,12 +193,15 @@ private void Log(string message)
LogText += $"[{DateTime.Now:HH:mm:ss}] {message}\n";
}
- [RelayCommand]
- private void ToggleLog() => IsLogVisible = !IsLogVisible;
-
[RelayCommand]
private void ClearLog() => LogText = "";
+ ///
+ /// Запрос диалога параметров штампа (реализуется окном).
+ /// Параметр — показывать ли пункт «подписывать копию со штампом».
+ ///
+ public Func>? RequestStampOptionsAsync { get; set; }
+
///
/// Запрос диалога выбора файлов; обрабатывается в MainWindow,
/// т.к. StorageProvider доступен только на уровне окна.
@@ -204,6 +223,22 @@ private void Log(string message)
/// Запрос диалога выбора документа для сборки криптоконтейнера.
public event EventHandler? BuildContainerRequested;
+ /// Запрос диалога выбора групповых подписей для разделения.
+ public event EventHandler? SplitSignaturesRequested;
+
+ /// Запрос диалога выбора подписи, из которой исключается подписант.
+ public event EventHandler? RemoveSignerRequested;
+
+ /// Выбор исключаемого подписанта (реализуется окном): Id или null при отмене.
+ public Func, Task>? RequestSignerChoiceAsync { get; set; }
+
+ ///
+ /// Открыть окно «Проверка ЭЦП» (реализуется окном). Пути подписи и документа
+ /// необязательны: если подпись указана, проверка запускается сразу.
+ /// Возвращает краткий итог последней проверки для лога (null — проверки не было).
+ ///
+ public Func>? ShowVerificationAsync { get; set; }
+
///
/// Запрос пароля у пользователя (заголовок, сообщение, предупреждение или null,
/// требуется ли повторный ввод). Возвращает пароль или null при отмене.
@@ -220,7 +255,28 @@ private void Log(string message)
// сертификатом без повторного выбора.
partial void OnSelectedCertificateChanged(CertificateItem? value)
{
- if (_initializing || value is null)
+ if (value is null)
+ return;
+
+ // Смена сертификата позволяет соподписать уже обработанные файлы. Обновление
+ // списка (тот же отпечаток, новый элемент) статусы не сбрасывает.
+ var changed = _activeThumbprint is not null
+ && !string.Equals(_activeThumbprint, value.Thumbprint, StringComparison.OrdinalIgnoreCase);
+ _activeThumbprint = value.Thumbprint;
+ if (changed)
+ {
+ var requeued = 0;
+ foreach (var file in Files.Where(f => f.Status == SignStatus.Signed))
+ {
+ file.ResetForSigning();
+ requeued++;
+ }
+
+ if (requeued > 0)
+ Log($"Сертификат сменён: подписанные файлы ({requeued}) снова в очереди — для соподписания.");
+ }
+
+ if (_initializing)
return;
_settings.SignCertThumbprint = value.Thumbprint;
_settings.Save();
@@ -306,19 +362,27 @@ private void ClearPoa()
///
/// Загружает и проверяет доверенность МЧД (как Контур: XML + подпись
/// руководителя, проверка подписи, срока и соответствия представителя
- /// выбранному сертификату).
+ /// выбранному сертификату). Проверка цепочки и отзыва идёт в фоне, на это
+ /// время команды недоступны. При восстановлении после запуска итог — в лог.
///
- public void SetPoa(string xmlPath, string sigPath, bool quiet = false)
+ public async Task SetPoaAsync(string xmlPath, string sigPath, bool restoring = false)
{
+ var prefix = restoring ? "Сохранённая МЧД: " : "";
+ IsBusy = true;
try
{
- var info = PowerOfAttorneyService.Parse(xmlPath, sigPath);
- var check = PowerOfAttorneyService.Validate(info, SelectedCertificate?.Certificate);
+ var options = CreateVerificationOptions();
+ var certificate = SelectedCertificate?.Certificate;
+ var (info, check) = await Task.Run(() =>
+ {
+ var parsed = PowerOfAttorneyService.Parse(xmlPath, sigPath);
+ return (parsed, PowerOfAttorneyService.Validate(parsed, certificate, options));
+ });
if (check.State == PowerOfAttorneyService.CheckState.Error)
{
Poa = null;
- StatusText = "Доверенность НЕ добавлена: " + check.Message;
+ Report("Доверенность НЕ добавлена: " + check.Message);
return;
}
@@ -331,19 +395,35 @@ public void SetPoa(string xmlPath, string sigPath, bool quiet = false)
+ (info.ValidTo is { } v ? $" (до {v:dd.MM.yyyy})" : "")
+ $", доверитель: {info.PrincipalOrg}, представитель: {info.RepresentativeName}. "
+ check.Message;
- if (check.State == PowerOfAttorneyService.CheckState.Warning)
- summary = "⚠ " + summary;
- if (!quiet)
- StatusText = summary;
- else
- Log(summary);
+ Report(check.State == PowerOfAttorneyService.CheckState.Warning ? "⚠ " + summary : summary);
}
catch (Exception ex)
{
Poa = null;
- if (!quiet)
- StatusText = "Не удалось загрузить МЧД: " + ex.Message;
+ Report("Не удалось загрузить МЧД: " + ex.Message);
}
+ finally
+ {
+ IsBusy = false;
+ }
+
+ void Report(string message)
+ {
+ if (restoring)
+ Log(prefix + message);
+ else
+ StatusText = message;
+ }
+ }
+
+ // Параметры проверки ЭЦП из настроек; пропущенные файлы доверенных корней — в лог.
+ private VerificationOptions CreateVerificationOptions()
+ {
+ var problems = new List();
+ var options = _settings.CreateVerificationOptions(problems);
+ foreach (var problem in problems)
+ Log("⚠ " + problem);
+ return options;
}
[RelayCommand]
@@ -642,7 +722,7 @@ public void AddFiles(params string[] filePaths)
if (Files.Any(f => string.Equals(f.FilePath, path, StringComparison.OrdinalIgnoreCase)))
continue;
- Files.Add(new SignFileItem(path));
+ Files.Add(new SignFileItem(path) { Owner = this });
added++;
}
@@ -658,7 +738,7 @@ public void AddFiles(params string[] filePaths)
}
/// Открыть диалог выбора подписей других лиц для файла.
- [RelayCommand]
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
private void AttachSignatures(SignFileItem item) => AttachSignaturesRequested?.Invoke(this, item);
[RelayCommand(CanExecute = nameof(CanBrowse))]
@@ -670,6 +750,18 @@ public void AddFiles(params string[] filePaths)
[RelayCommand(CanExecute = nameof(CanBrowse))]
private void BuildContainer() => BuildContainerRequested?.Invoke(this, EventArgs.Empty);
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private Task VerifySignature() => OpenVerificationAsync(null, null);
+
+ private async Task OpenVerificationAsync(string? signaturePath, string? documentPath)
+ {
+ if (ShowVerificationAsync is null)
+ return;
+ var summary = await ShowVerificationAsync(signaturePath, documentPath);
+ if (summary is not null)
+ StatusText = summary;
+ }
+
///
/// Собирает прикреплённый криптоконтейнер (документ + имеющиеся подписи)
/// без создания своей подписи.
@@ -702,41 +794,239 @@ public async Task BuildContainerAsync(string documentPath, IReadOnlyList
[RelayCommand(CanExecute = nameof(CanStampOnly))]
private void StampOnly() => StampOnlyRequested?.Invoke(this, EventArgs.Empty);
+ /// Начальные параметры диалога штампа — из сохранённых настроек.
+ public PdfStamper.StampOptions BuildInitialStampOptions() => new()
+ {
+ WithDate = StampWithDate,
+ LogoPath = StampLogoPath,
+ Pages = Enum.TryParse(_settings.StampPagesMode, out var p)
+ ? p
+ : PdfStamper.StampPages.Last,
+ CustomPages = _settings.StampCustomPages,
+ };
+
+ /// Запоминает параметры, выбранные в диалоге штампа.
+ public void SaveStampOptions(Views.StampOptionsDialog.Result result)
+ {
+ StampWithDate = result.Options.WithDate;
+ StampLogoPath = result.Options.LogoPath;
+ StampSignCopy = result.SignCopy;
+ _settings.StampPagesMode = result.Options.Pages.ToString();
+ _settings.StampCustomPages = result.Options.CustomPages;
+ _settings.Save();
+ }
+
///
- /// Ставит визуальный штамп на выбранные PDF без подписания: рядом сохраняется
- /// копия «имя (со штампом).pdf». Пароль не нужен — используются только
- /// данные сертификата (открытая часть).
+ /// Ставит визуальный штамп на PDF без подписания. Если рядом с документом
+ /// есть «имя.sig», плашки ставятся для ВСЕХ его подписантов; иначе —
+ /// по данным выбранного сертификата.
///
- public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths)
+ public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfStamper.StampOptions options)
{
if (pdfPaths.Count == 0 || SelectedCertificate is not { } item)
return;
+ options = options with { PoaNumber = options.PoaNumber ?? Poa?.Number };
IsBusy = true;
try
{
- var lines = new List();
foreach (var path in pdfPaths)
{
+ var name = System.IO.Path.GetFileName(path);
try
{
if (!PdfStamper.IsPdf(path))
{
- lines.Add($"«{System.IO.Path.GetFileName(path)}»: пропущен (не PDF)");
+ Log($"«{name}»: пропущен (не PDF)");
continue;
}
- var stamped = await Task.Run(() => PdfStamper.CreateStampedCopy(
- path, item.Certificate, StampWithDate, StampLogoPath, DateTime.Now, Poa?.Number));
- lines.Add($"«{System.IO.Path.GetFileName(path)}» → «{System.IO.Path.GetFileName(stamped)}»");
+ var stamped = await Task.Run(() =>
+ {
+ var sigPath = path + ".sig";
+ var certs = System.IO.File.Exists(sigPath)
+ ? CmsMerger.GetSignerCertificates(System.IO.File.ReadAllBytes(sigPath))
+ : Array.Empty();
+ try
+ {
+ return PdfStamper.CreateStampedCopy(
+ path,
+ certs.Count > 0 ? certs : new[] { item.Certificate },
+ options,
+ DateTime.Now);
+ }
+ finally
+ {
+ foreach (var c in certs)
+ c.Dispose();
+ }
+ });
+ Log($"Штамп без подписания: «{name}» → «{System.IO.Path.GetFileName(stamped)}»");
}
catch (Exception ex)
{
- lines.Add($"«{System.IO.Path.GetFileName(path)}»: ошибка — {ex.Message}");
+ Log($"Штамп без подписания: «{name}» — ошибка: {ex.Message}");
+ }
+ }
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ /// ПКМ: подписанты и проверка ЭЦП для файла из списка (.sig рядом).
+ [RelayCommand]
+ private async Task InspectSignaturesAsync(SignFileItem item)
+ {
+ var sigPath = item.FilePath + ".sig";
+ if (!System.IO.File.Exists(sigPath))
+ {
+ StatusText = $"Рядом с «{item.FileName}» нет файла подписи «{System.IO.Path.GetFileName(sigPath)}».";
+ return;
+ }
+
+ await OpenVerificationAsync(sigPath, item.FilePath);
+ }
+
+ /// ПКМ: штамп для файла из списка (диалог параметров → копия без подписи).
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private async Task StampItemAsync(SignFileItem item)
+ {
+ if (RequestStampOptionsAsync is null)
+ return;
+ var result = await RequestStampOptionsAsync(false);
+ if (result is null)
+ return;
+ SaveStampOptions(result);
+ await StampWithoutSigningAsync(new[] { item.FilePath }, result.Options);
+ }
+
+ /// ПКМ: извлечь из контейнера «имя.sig» рядом с файлом.
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private async Task ExtractItemAsync(SignFileItem item)
+ {
+ var sigPath = item.FilePath + ".sig";
+ if (!System.IO.File.Exists(sigPath))
+ {
+ StatusText = $"Рядом с «{item.FileName}» нет файла подписи для извлечения.";
+ return;
+ }
+
+ await ExtractContainersAsync(new[] { sigPath });
+ }
+
+ /// ПКМ: собрать криптоконтейнер из файла и его подписи рядом.
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private Task BuildContainerItemAsync(SignFileItem item) =>
+ BuildContainerAsync(item.FilePath, null);
+
+ /// ПКМ: разделить групповую подпись «имя.sig» рядом с файлом по подписантам.
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private async Task SplitItemAsync(SignFileItem item)
+ {
+ var sigPath = item.FilePath + ".sig";
+ if (!System.IO.File.Exists(sigPath))
+ {
+ StatusText = $"Рядом с «{item.FileName}» нет файла подписи для разделения.";
+ return;
+ }
+
+ await SplitSignatureFilesAsync(new[] { sigPath });
+ }
+
+ /// ПКМ: исключить подписанта из «имя.sig» рядом с файлом.
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private async Task RemoveSignerItemAsync(SignFileItem item)
+ {
+ var sigPath = item.FilePath + ".sig";
+ if (!System.IO.File.Exists(sigPath))
+ {
+ StatusText = $"Рядом с «{item.FileName}» нет файла подписи.";
+ return;
+ }
+
+ await RemoveSignerFromFileAsync(sigPath);
+ }
+
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private void SplitSignatures() => SplitSignaturesRequested?.Invoke(this, EventArgs.Empty);
+
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private void RemoveSigner() => RemoveSignerRequested?.Invoke(this, EventArgs.Empty);
+
+ /// Разделяет групповые подписи на отдельные .sig по подписантам.
+ public async Task SplitSignatureFilesAsync(IReadOnlyList paths)
+ {
+ if (paths.Count == 0)
+ return;
+
+ IsBusy = true;
+ try
+ {
+ foreach (var path in paths)
+ {
+ var name = System.IO.Path.GetFileName(path);
+ try
+ {
+ var r = await Task.Run(() => CmsExtractor.SplitSignatureFile(path));
+ StatusText = $"«{name}»: создано отдельных подписей: {r.SignerFiles.Count} ("
+ + string.Join(", ", r.SignerFiles.Select(System.IO.Path.GetFileName)) + ")"
+ + (r.DocumentPath is null ? "" : $"; документ → {System.IO.Path.GetFileName(r.DocumentPath)}");
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Разделение «{name}»: {ex.Message}";
}
}
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ ///
+ /// Исключает подписанта: выбор в диалоге → новый файл «имя (без подписанта).sig»
+ /// с остальными подписями. Исходный файл не меняется.
+ ///
+ public async Task RemoveSignerFromFileAsync(string path)
+ {
+ if (RequestSignerChoiceAsync is null)
+ return;
+
+ var name = System.IO.Path.GetFileName(path);
+ IReadOnlyList signers;
+ try
+ {
+ signers = await Task.Run(() => CmsExtractor.ListSigners(path));
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Не удалось прочитать подпись «{name}»: {ex.Message}";
+ return;
+ }
+
+ if (signers.Count < 2)
+ {
+ StatusText = $"«{name}»: подписант один — последнего подписанта исключить нельзя.";
+ return;
+ }
+
+ var signerId = await RequestSignerChoiceAsync(signers);
+ if (signerId is null)
+ return;
- StatusText = "Штамп без подписания: " + string.Join(" | ", lines);
+ IsBusy = true;
+ try
+ {
+ var r = await Task.Run(() => CmsExtractor.RemoveSignerFromFile(path, signerId));
+ StatusText = $"Подписант исключён → «{System.IO.Path.GetFileName(r.OutputPath)}», "
+ + $"осталось подписантов: {r.SignerCount}. Исходный «{name}» не изменён.";
+ }
+ catch (Exception ex)
+ {
+ StatusText = "Не удалось исключить подписанта: " + ex.Message;
}
finally
{
@@ -744,6 +1034,24 @@ public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths)
}
}
+ /// ПКМ: открыть папку файла.
+ [RelayCommand]
+ private void OpenFolder(SignFileItem item)
+ {
+ try
+ {
+ var directory = System.IO.Path.GetDirectoryName(item.FilePath) ?? ".";
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(directory)
+ {
+ UseShellExecute = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ StatusText = "Не удалось открыть папку: " + ex.Message;
+ }
+ }
+
/// Объединяет выбранные файлы подписей в один — без создания своей подписи.
public async Task MergeSignatureFilesAsync(IReadOnlyList paths)
{
@@ -852,17 +1160,21 @@ private async Task SignAllAsync()
return;
}
- // Перед подписанием с МЧД перепроверяем её против фактического сертификата.
- if (Poa is { } poaCheck)
+ // Параметры штампа спрашиваем один раз на весь пакет.
+ PdfStamper.StampOptions? stampParameters = null;
+ if (UseStamp)
{
- var check = PowerOfAttorneyService.Validate(poaCheck, certificate);
- if (check.State == PowerOfAttorneyService.CheckState.Error)
+ if (RequestStampOptionsAsync is null)
+ return;
+ var stampResult = await RequestStampOptionsAsync(true);
+ if (stampResult is null)
{
- StatusText = "Подписание остановлено — проблема с МЧД: " + check.Message;
+ StatusText = "Подписание отменено (диалог штампа закрыт).";
return;
}
- if (check.State == PowerOfAttorneyService.CheckState.Warning)
- Log("⚠ МЧД: " + check.Message);
+
+ SaveStampOptions(stampResult);
+ stampParameters = stampResult.Options;
}
IsBusy = true;
@@ -872,6 +1184,30 @@ private async Task SignAllAsync()
try
{
+ // МЧД проверяется один раз на пакет — против фактического сертификата;
+ // в каталоги документов копируется именно проверенный снимок файлов.
+ PowerOfAttorneyService.PoaPackage? poaPackage = null;
+ if (Poa is { } poa)
+ {
+ var poaOptions = CreateVerificationOptions();
+ try
+ {
+ poaPackage = await Task.Run(() => PowerOfAttorneyService.Prepare(poa, certificate, poaOptions));
+ }
+ catch (Exception ex)
+ {
+ StatusText = "Подписание остановлено — не удалось проверить МЧД: " + ex.Message;
+ return;
+ }
+
+ if (poaPackage.Check.State == PowerOfAttorneyService.CheckState.Error)
+ {
+ StatusText = "Подписание остановлено — проблема с МЧД: " + poaPackage.Check.Message;
+ return;
+ }
+ if (poaPackage.Check.State == PowerOfAttorneyService.CheckState.Warning)
+ Log("⚠ МЧД: " + poaPackage.Check.Message);
+ }
foreach (var file in Files.Where(f => f.Status != SignStatus.Signed).ToList())
{
file.Status = SignStatus.Signing;
@@ -885,14 +1221,16 @@ private async Task SignAllAsync()
{
Detached = IsDetached,
MergeWithExisting = MergeWithExisting,
- ExtraSignatures = file.ExtraSignatures,
+ ExtraSignatures = file.ExtraSignatures.ToList(),
Timestamp = UseTimestamp,
TsaUrl = TsaUrl,
Stamp = UseStamp,
StampSignCopy = StampSignCopy,
StampWithDate = StampWithDate,
StampLogoPath = StampLogoPath,
- PowerOfAttorney = Poa,
+ StampParameters = stampParameters,
+ PowerOfAttorney = poaPackage?.Info,
+ PreparedPowerOfAttorney = poaPackage,
};
var result = await Task.Run(
() => _documentSigner.SignFileAsync(file.FilePath, certificate, options));
@@ -925,6 +1263,11 @@ private async Task SignAllAsync()
}
}
}
+ catch (Exception ex)
+ {
+ StatusText = "Подписание остановлено: " + ex.Message;
+ return;
+ }
finally
{
IsBusy = false;
diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs
index 896428d..c181bb5 100644
--- a/src/SignService/ViewModels/SignFileItem.cs
+++ b/src/SignService/ViewModels/SignFileItem.cs
@@ -30,6 +30,12 @@ public SignFileItem(string filePath)
public string FilePath { get; }
+ ///
+ /// Модель окна, в очереди которой стоит файл: из контекстного меню файла
+ /// (всплывающее окно вне дерева элементов) команды доступны только через неё.
+ ///
+ public MainWindowViewModel? Owner { get; init; }
+
public string FileName { get; }
public string SizeDisplay { get; }
@@ -80,9 +86,21 @@ public int AttachSignatures(IEnumerable sigPaths)
}
ExtraCount = _extraSignatures.Count;
+ // Новая подпись другого лица должна попасть в .sig — файл снова ждёт подписания.
+ if (added > 0 && Status == SignStatus.Signed)
+ ResetForSigning();
return added;
}
+ /// Возвращает файл в очередь (после смены сертификата или новых подписей).
+ public void ResetForSigning()
+ {
+ Status = SignStatus.Pending;
+ Message = null;
+ SignaturePath = null;
+ SignerCount = 0;
+ }
+
public string StatusDisplay => Status switch
{
SignStatus.Pending => "Ожидает",
diff --git a/src/SignService/Views/MainWindow.axaml b/src/SignService/Views/MainWindow.axaml
index a57b140..6443c76 100644
--- a/src/SignService/Views/MainWindow.axaml
+++ b/src/SignService/Views/MainWindow.axaml
@@ -9,7 +9,7 @@
WindowStartupLocation="CenterScreen"
DragDrop.AllowDrop="True">
-
+
@@ -72,53 +72,17 @@
IsChecked="{Binding MergeWithExisting}"
Content="Объединять с существующей .sig"
ToolTip.Tip="Соподписание: если рядом с документом уже есть .sig, ваша подпись добавится к имеющимся подписантам, а не заменит их. Подписи других лиц можно также приложить кнопкой «+.sig» у файла."/>
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ToolTip.Tip="Перед подписанием откроется диалог параметров штампа (страницы, дата, лого). По умолчанию подписывается ОРИГИНАЛ, а копия «имя (со штампом).pdf» — отдельный файл без подписи с плашками всех подписантов (безопасно при соподписании)."/>
-
+