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» — отдельный файл без подписи с плашками всех подписантов (безопасно при соподписании)."/> - + + + + + + + + +