diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8f660c4..466185b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,8 +2,10 @@ name: Build
on:
push:
- branches: ["main", "claude/**"]
+ branches: ["main", "claude/**", "codex/**"]
tags: ["v*"]
+ pull_request:
+ branches: ["main"]
workflow_dispatch:
inputs:
release_tag:
@@ -30,6 +32,7 @@ jobs:
build-windows:
name: Windows x64 exe
+ needs: tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
@@ -38,6 +41,9 @@ jobs:
with:
dotnet-version: 8.0.x
+ - 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18a2b91..ecde59b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
## v1.1.0 — не выпущена
+- **Управление групповой подписью**: отдельные пункты «Разделить групповую
+ подпись…» и «Исключить подписанта из .sig…». Выбор подписанта по имени и
+ номеру сертификата; исходный файл сохраняется, удаление последнего запрещено.
+- **МЧД**: криптографическая проверка всех подписей, включая ГОСТ через
+ CryptoAPI; актуальные срок и представитель считываются из проверяемого XML.
+ Проверка и копирование используют одни байты; коллизия с другой МЧД
+ останавливает подписание до изменения существующей `.sig`.
+- **Соподписание**: исправлено сохранение старого вложенного документа после
+ исключения устаревшего подписанта; выбранный режим откреплённой подписи
+ соблюдается и при объединении с прикреплённым контейнером.
+- Добавление `.sig` и смена сертификата возвращают подписанные файлы в очередь.
+- **Обновление**: атомарная замена EXE с резервной копией и откатом при отказе
+ запуска; ограниченное ожидание выхода, безопасная передача путей и журнал.
+- Добавлены регрессионные тесты, настройки тестов изолированы от пользовательских;
+ сборка и публикация релиза в CI зависят от успешного выполнения тестов.
+
- **Штамп на PDF безопасен при соподписании**: по умолчанию подписывается
ОРИГИНАЛ документа, а копия «имя (со штампом).pdf» создаётся отдельно
без подписи — после подписания и со всеми подписантами итоговой подписи
diff --git a/README.en.md b/README.en.md
index 8dc1e5d..bc6ee38 100644
--- a/README.en.md
+++ b/README.en.md
@@ -56,6 +56,12 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- **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 group signature…** — a dedicated Tools command creates a detached
+ `.sig` for every signer and saves the embedded document, if present.
+ Existing files are preserved and duplicate names receive a numeric suffix;
+- **Remove signer from .sig…** — select a signer by name and certificate serial
+ number. A new signature file is created with the remaining signatures and
+ embedded document. The original file is kept; removing the last signer is blocked;
- **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,14 +71,19 @@ 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
+- the app cryptographically verifies all signatures over the MChD XML
+ (CryptoAPI with an installed CSP for GOST on Windows), checks the validity
+ period, and verifies that the
representative in the MChD matches the selected certificate by INN/SNILS;
- 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 XML and signature are re-read on every signing; validation and copying
+ use the same file snapshot. Conflicts with a different MChD in the output
+ directory stop signing before overwriting an existing document signature;
+- this check does not validate CA trust, certificate revocation, the head's
+ authority, or the power of attorney's registry/revocation status.
### Visual stamp on PDF
@@ -107,6 +118,9 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- 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).
+ The executable is replaced atomically with a `.bak` backup and rollback if
+ launching fails. Waiting for exit is limited to 30 seconds; the update log
+ is saved beside the downloaded executable in the temporary directory.
## Requirements
diff --git a/README.md b/README.md
index 9878f0d..0dc7508 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,13 @@
- **Извлечь из .sig…** — из криптоконтейнера достаются: вложенный документ
(байт-в-байт), откреплённая подпись со всеми подписантами и отдельные `.sig`
по каждому подписанту (в имени — ФИО из сертификата);
+- **Разделить групповую подпись…** — отдельный пункт меню «Инструменты»:
+ создаёт откреплённую `.sig` для каждого подписанта; если документ был внутри
+ контейнера, сохраняет и его. При совпадении имён добавляется номер;
+- **Исключить подписанта из .sig…** — выбор по ФИО и номеру сертификата,
+ результат сохраняется в новый файл «имя (без подписанта).sig»;
+ остальные подписи и вложенный документ сохраняются, последний подписант
+ не удаляется. Исходные файлы обеих операций остаются без изменений;
- **Собрать контейнер…** — документ упаковывается в прикреплённый `.sig`
вместе с имеющимися подписями (обратная операция к извлечению);
- **Штамп на PDF…** — копия документа с плашкой о подписании без подписи.
@@ -59,14 +66,19 @@
- кнопка «Добавить доверенность…» — как в Контуре: выбираются файл МЧД
(XML, формат EMCHD_1) и подпись руководителя (.sig; `имя.xml.sig` рядом
подхватывается автоматически);
-- программа проверяет: подпись руководителя соответствует файлу МЧД
- (по хешу, для ГОСТ — Стрибогом), срок действия не истёк, представитель
+- программа проверяет криптографическую корректность всех подписей МЧД
+ (для ГОСТ на Windows — через CryptoAPI и установленный CSP), срок действия
+ не истёк, представитель
в МЧД совпадает с владельцем выбранного сертификата по ИНН/СНИЛС;
- при подписании файлы МЧД (XML + .sig) копируются рядом с подписанным
документом (в CMS-подпись МЧД не вкладывается — так же поступает Контур),
а в визуальный штамп на PDF добавляется строка
«Действует на основании МЧД № …»;
-- доверенность запоминается и перепроверяется при каждом подписании.
+- доверенность запоминается; при каждом подписании XML и подпись заново
+ считываются, проверяются и копируются из одного снимка файлов. При конфликте
+ с другим комплектом МЧД в выходном каталоге подписание останавливается;
+- проверка МЧД не проверяет доверие цепочке УЦ, отзыв сертификата,
+ полномочия руководителя и отзыв доверенности в реестре.
### Визуальный штамп на PDF
@@ -103,6 +115,9 @@
проверка обновлений;
- **автообновление**: проверка новых версий на GitHub Releases при запуске
(отключается) и обновление в один клик из окна «О программе» (Windows).
+ Замена выполняется атомарно с резервной копией `.bak` рядом с EXE;
+ при отказе запуска выполняется откат. Ожидание выхода ограничено 30 секундами,
+ журнал установки сохраняется рядом со скачанным файлом во временном каталоге.
## Требования
diff --git a/src/SignService/Services/AppSettings.cs b/src/SignService/Services/AppSettings.cs
index 9e54390..bd97eda 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; }
@@ -85,8 +97,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/CertificateVault.cs b/src/SignService/Services/CertificateVault.cs
index 8029c4b..e514a0c 100644
--- a/src/SignService/Services/CertificateVault.cs
+++ b/src/SignService/Services/CertificateVault.cs
@@ -20,9 +20,14 @@ namespace SignService.Services;
///
public class CertificateVault
{
- private static readonly string VaultDir = Path.Combine(
+ private readonly string VaultDir;
+
+ public CertificateVault(string? vaultDirectory = null)
+ {
+ VaultDir = vaultDirectory is not null ? Path.GetFullPath(vaultDirectory) : Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SignService", "certificates");
+ }
///
/// Сохраняет сертификат с закрытым ключом в защищённый паролем PFX.
diff --git a/src/SignService/Services/CmsExtractor.cs b/src/SignService/Services/CmsExtractor.cs
index 9f698ab..4b20cab 100644
--- a/src/SignService/Services/CmsExtractor.cs
+++ b/src/SignService/Services/CmsExtractor.cs
@@ -13,6 +13,34 @@ 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);
+ }
+
+ /// Создаёт новый контейнер без выбранного подписанта. Исходный файл сохраняется.
+ 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 });
+ using (var stream = new FileStream(output, FileMode.CreateNew, FileAccess.Write))
+ stream.Write(signature);
+ return new RemoveSignerResult(output, CmsMerger.CountSigners(signature));
+ }
+
public sealed record ExtractionResult(
string ContainerName,
bool WasAttached,
diff --git a/src/SignService/Services/CmsMerger.cs b/src/SignService/Services/CmsMerger.cs
index eaffbaa..fe973cd 100644
--- a/src/SignService/Services/CmsMerger.cs
+++ b/src/SignService/Services/CmsMerger.cs
@@ -86,7 +86,17 @@ public static MergeResult MergeForDocument(IReadOnlyList signatures, byt
throw new InvalidOperationException(
"Все объединяемые подписи не соответствуют текущему содержимому документа.");
- var merged = BuildMerged(parsed);
+ // Содержимое входного контейнера может относиться к исключённой старой
+ // подписи. Собираем результат только с текущим документом.
+ var attached = parsed.Any(p => p.HasContent);
+ foreach (var p in parsed)
+ {
+ p.EncapContentInfo = StripContent(p.EncapContentInfo);
+ p.HasContent = false;
+ }
+ var merged = BuildMerged(parsed.Where(p => p.Signers.Count > 0).ToList());
+ if (attached)
+ merged = AttachContent(merged, document);
return new MergeResult(
merged,
CountSigners(merged),
@@ -190,6 +200,42 @@ public static ContainerInfo Inspect(byte[] signature)
return new ContainerInfo(parsed.HasContent, names);
}
+ 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; // 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 — если подпись откреплённая (документа внутри нет).
diff --git a/src/SignService/Services/CmsVerifier.cs b/src/SignService/Services/CmsVerifier.cs
new file mode 100644
index 0000000..5d7881d
--- /dev/null
+++ b/src/SignService/Services/CmsVerifier.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Security.Cryptography;
+using System.Security.Cryptography.Pkcs;
+
+namespace SignService.Services;
+
+/// Криптографическая проверка всех подписантов; доверие УЦ и отзыв проверяются отдельно.
+internal static class CmsVerifier
+{
+ public static void VerifyDocument(byte[] signature, byte[] document)
+ {
+ var normalized = CmsMerger.Normalize(signature);
+ var embedded = CmsMerger.ExtractContent(normalized);
+ if (embedded is not null && !embedded.AsSpan().SequenceEqual(document))
+ throw new CryptographicException("Вложенный документ не соответствует файлу МЧД.");
+ var signers = CmsMerger.SplitBySigner(normalized);
+ if (signers.Count == 0)
+ throw new CryptographicException("В файле нет подписантов.");
+
+ // Проверяем каждый исходный SignerInfo, включая повторные записи одного SID.
+ foreach (var (_, detached) in signers)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ // CryptoAPI передаёт ГОСТ-проверку установленному CSP, как и подписание.
+ NativeSign.VerifyDetached(detached, document, 0);
+ }
+ else
+ {
+ var cms = new SignedCms(new ContentInfo(document), detached: true);
+ cms.Decode(detached);
+ cms.CheckSignature(verifySignatureOnly: true);
+ }
+ }
+ }
+}
diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs
index 365315d..7e1603d 100644
--- a/src/SignService/Services/DocumentSigner.cs
+++ b/src/SignService/Services/DocumentSigner.cs
@@ -112,6 +112,13 @@ public async Task SignFileAsync(
SignOptions options,
CancellationToken cancellationToken = default)
{
+ PowerOfAttorneyService.Snapshot? poaSnapshot = null;
+ if (options.PowerOfAttorney is { } requestedPoa)
+ {
+ poaSnapshot = PowerOfAttorneyService.ReadValidated(requestedPoa, certificate);
+ if (poaSnapshot.Check.State == PowerOfAttorneyService.CheckState.Error)
+ throw new InvalidOperationException(poaSnapshot.Check.Message);
+ }
// Режим «подписывать копию со штампом»: штамп меняет содержимое PDF,
// поэтому копия создаётся ДО подписания и подписывается именно она.
// В режиме «копия отдельно» подписывается ОРИГИНАЛ, а штампованная копия
@@ -124,7 +131,7 @@ public async Task SignFileAsync(
{
targetPath = PdfStamper.CreateStampedCopy(
filePath, certificate, options.StampWithDate, options.StampLogoPath, DateTime.Now,
- options.PowerOfAttorney?.Number);
+ poaSnapshot?.Info.Number);
}
catch (Exception e)
{
@@ -163,9 +170,10 @@ public async Task SignFileAsync(
int signerCount;
IReadOnlyList excluded;
IReadOnlyList unverified;
+ byte[] resultSignature;
if (inputs.Count == 1)
{
- await File.WriteAllBytesAsync(signaturePath, own, cancellationToken);
+ resultSignature = own;
signerCount = 1;
excluded = Array.Empty();
unverified = Array.Empty();
@@ -175,14 +183,30 @@ public async Task SignFileAsync(
// Объединение с проверкой: подписи под другим файлом или прежней версией
// документа исключаются — иначе портал отклонит весь контейнер.
var merged = CmsMerger.MergeForDocument(inputs, data);
- await File.WriteAllBytesAsync(signaturePath, merged.Signature, cancellationToken);
+ resultSignature = options.Detached
+ ? CmsMerger.ConvertToDetached(merged.Signature)
+ : CmsMerger.AttachContent(merged.Signature, data);
signerCount = merged.SignerCount;
excluded = merged.ExcludedSigners;
unverified = merged.UnverifiedSigners;
}
- if (options.PowerOfAttorney is { } poa)
- PowerOfAttorneyService.CopyNextToDocument(poa, targetPath);
+ if (poaSnapshot is not null)
+ PowerOfAttorneyService.CopyNextToDocument(poaSnapshot, targetPath);
+
+ // Сбой или отмена записи не должны уничтожать предыдущую подпись.
+ var temporarySignature = signaturePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ await File.WriteAllBytesAsync(temporarySignature, resultSignature, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ File.Move(temporarySignature, signaturePath, overwrite: true);
+ }
+ finally
+ {
+ if (File.Exists(temporarySignature))
+ File.Delete(temporarySignature);
+ }
// Режим «копия отдельно»: штампованная копия БЕЗ подписи, со всеми
// подписантами итогового .sig — пересоздаётся после каждого подписания.
@@ -198,7 +222,7 @@ public async Task SignFileAsync(
: new List { certificate };
stampedCopyPath = PdfStamper.CreateStampedCopy(
filePath, stampCerts, options.StampWithDate, options.StampLogoPath, DateTime.Now,
- options.PowerOfAttorney?.Number);
+ poaSnapshot?.Info.Number);
foreach (var c in signerCerts)
c.Dispose();
}
diff --git a/src/SignService/Services/NativeSign.cs b/src/SignService/Services/NativeSign.cs
index 299904b..247808c 100644
--- a/src/SignService/Services/NativeSign.cs
+++ b/src/SignService/Services/NativeSign.cs
@@ -80,6 +80,44 @@ private static extern bool CryptSignMessage(
byte[]? pbSignedBlob,
ref uint pcbSignedBlob);
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CryptVerifyMessagePara
+ {
+ public uint cbSize;
+ public uint dwMsgAndCertEncodingType;
+ public IntPtr hCryptProv;
+ public IntPtr pfnGetSignerCertificate;
+ public IntPtr pvGetArg;
+ }
+
+ [DllImport("crypt32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CryptVerifyDetachedMessageSignature(
+ ref CryptVerifyMessagePara verifyPara, uint signerIndex,
+ byte[] signature, uint signatureSize, uint contentCount,
+ IntPtr[] contents, uint[] contentSizes, IntPtr signerCertificate);
+
+ public static void VerifyDetached(byte[] signature, byte[] document, uint signerIndex)
+ {
+ var dataHandle = GCHandle.Alloc(document, GCHandleType.Pinned);
+ try
+ {
+ var para = new CryptVerifyMessagePara
+ {
+ cbSize = (uint)Marshal.SizeOf(),
+ dwMsgAndCertEncodingType = MsgEncoding,
+ };
+ if (!CryptVerifyDetachedMessageSignature(ref para, signerIndex,
+ signature, (uint)signature.Length, 1,
+ new[] { dataHandle.AddrOfPinnedObject() }, new[] { (uint)document.Length }, IntPtr.Zero))
+ throw new System.Security.Cryptography.CryptographicException(Marshal.GetLastWin32Error());
+ }
+ finally
+ {
+ dataHandle.Free();
+ }
+ }
+
/// Подписанный (authenticated) атрибут: OID и DER-байты значения.
public readonly record struct AuthAttribute(string Oid, byte[] DerValue);
diff --git a/src/SignService/Services/PowerOfAttorneyService.cs b/src/SignService/Services/PowerOfAttorneyService.cs
index 211bccf..434ab66 100644
--- a/src/SignService/Services/PowerOfAttorneyService.cs
+++ b/src/SignService/Services/PowerOfAttorneyService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Formats.Asn1;
using System.Globalization;
using System.IO;
@@ -48,7 +49,13 @@ 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 stream = new MemoryStream(xml, writable: false);
+ var doc = XDocument.Load(stream);
var root = doc.Root ?? throw new InvalidOperationException("Пустой XML-файл доверенности.");
if (root.Name.LocalName != "Доверенность")
throw new InvalidOperationException(
@@ -93,36 +100,43 @@ public static PoaInfo Parse(string xmlPath, string sigPath)
}
///
- /// Проверяет доверенность: подпись руководителя соответствует XML (по messageDigest,
- /// для ГОСТ — Стрибогом), срок действия не истёк, представитель в МЧД совпадает
+ /// Проверяет доверенность: криптографическую подпись XML, срок действия, соответствие представителя
/// с выбранным сертификатом (по ИНН/СНИЛС из subject).
///
public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertificate)
{
- // 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 ReadValidated(poa, signerCertificate).Check;
}
catch (Exception e)
{
- return new CheckResult(CheckState.Error, "Не удалось разобрать подпись руководителя: " + e.Message);
+ return new CheckResult(CheckState.Error, "Не удалось проверить МЧД и подпись руководителя: " + e.Message);
}
+ }
+
+ // Сведения, проверка и копирование относятся к одним и тем же байтам файлов.
+ internal sealed record Snapshot(PoaInfo Info, byte[] Xml, byte[] Signature, CheckResult Check);
+
+ internal static Snapshot ReadValidated(PoaInfo poa, X509Certificate2? signerCertificate)
+ {
+ var xml = File.ReadAllBytes(poa.XmlPath);
+ var sig = File.ReadAllBytes(poa.SigPath);
+ var current = ParseBytes(xml, poa.XmlPath, poa.SigPath);
+ CmsVerifier.VerifyDocument(sig, xml);
+ return new Snapshot(current, xml, sig, ValidateMetadata(current, signerCertificate));
+ }
+ private static CheckResult ValidateMetadata(PoaInfo poa, X509Certificate2? signerCertificate)
+ {
// 2. Срок действия
+ if (poa.ValidTo is null)
+ return new CheckResult(CheckState.Error, "В МЧД отсутствует корректный срок действия.");
if (poa.ValidTo is { } validTo && validTo.Date < DateTime.Today)
return new CheckResult(CheckState.Error,
$"Срок действия МЧД истёк {validTo:dd.MM.yyyy}.");
+ if (poa.IssueDate is { } issued && issued.Date > DateTime.Today)
+ return new CheckResult(CheckState.Error, "Дата выдачи МЧД ещё не наступила.");
// 3. Представитель соответствует сертификату подписанта
if (signerCertificate is not null)
@@ -136,6 +150,10 @@ public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertific
var innOk = certInn.Length > 0 && poaInn.Length > 0 && certInn == poaInn;
var snilsOk = certSnils.Length > 0 && poaSnils.Length > 0 && certSnils == poaSnils;
+ if ((certInn.Length > 0 && poaInn.Length > 0 && !innOk)
+ || (certSnils.Length > 0 && poaSnils.Length > 0 && !snilsOk))
+ return new CheckResult(CheckState.Error, "ИНН/СНИЛС представителя МЧД противоречат сертификату.");
+
if (!innOk && !snilsOk)
{
if (certInn.Length == 0 && certSnils.Length == 0)
@@ -146,8 +164,7 @@ public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertific
}
}
- return new CheckResult(CheckState.Ok, "Доверенность проверена: подпись руководителя "
- + "соответствует МЧД, срок действия не истёк"
+ return new CheckResult(CheckState.Ok, "Криптографическая подпись МЧД проверена, срок действия не истёк"
+ (signerCertificate is null ? "." : ", представитель совпадает с сертификатом."));
}
@@ -157,12 +174,47 @@ public static CheckResult Validate(PoaInfo poa, X509Certificate2? signerCertific
///
public static void CopyNextToDocument(PoaInfo poa, string documentPath)
{
+ CopyNextToDocument(ReadValidated(poa, null), documentPath);
+ }
+
+ internal static void CopyNextToDocument(Snapshot snapshot, string documentPath)
+ {
+ if (snapshot.Check.State == CheckState.Error)
+ throw new InvalidOperationException(snapshot.Check.Message);
var directory = Path.GetDirectoryName(Path.GetFullPath(documentPath)) ?? ".";
- foreach (var source in new[] { poa.XmlPath, poa.SigPath })
+ var files = new[]
+ {
+ (Path: Path.Combine(directory, Path.GetFileName(snapshot.Info.XmlPath)), Data: snapshot.Xml),
+ (Path: Path.Combine(directory, Path.GetFileName(snapshot.Info.SigPath)), Data: snapshot.Signature),
+ };
+ if (string.Equals(files[0].Path, files[1].Path, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("XML и подпись МЧД должны иметь разные имена файлов.");
+ // Проверяем обе коллизии до записи любого файла.
+ foreach (var file in files)
+ if (File.Exists(file.Path) && !File.ReadAllBytes(file.Path).AsSpan().SequenceEqual(file.Data))
+ throw new IOException($"Рядом с документом уже есть другой файл МЧД «{Path.GetFileName(file.Path)}». Переместите его или выберите другой каталог.");
+
+ var created = new List();
+ try
+ {
+ foreach (var file in files)
+ {
+ if (File.Exists(file.Path))
+ {
+ if (!File.ReadAllBytes(file.Path).AsSpan().SequenceEqual(file.Data))
+ throw new IOException("Файл МЧД изменился во время копирования: " + file.Path);
+ continue;
+ }
+ using var output = new FileStream(file.Path, FileMode.CreateNew, FileAccess.Write);
+ created.Add(file.Path);
+ output.Write(file.Data);
+ }
+ }
+ catch
{
- var target = Path.Combine(directory, Path.GetFileName(source));
- if (!File.Exists(target))
- File.Copy(source, target);
+ foreach (var path in created)
+ try { File.Delete(path); } catch { }
+ throw;
}
}
diff --git a/src/SignService/Services/UpdateService.cs b/src/SignService/Services/UpdateService.cs
index dee39f4..e68b4d3 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;
@@ -14,7 +15,7 @@ namespace SignService.Services;
///
/// Автообновление программы через GitHub Releases: проверка последнего релиза,
/// скачивание нового SignService.exe и самозамена (Windows) через командный
-/// скрипт, который дожидается выхода программы, подменяет exe и запускает его.
+/// помощник, который дожидается выхода программы и заменяет exe с резервной копией.
///
public class UpdateService
{
@@ -104,7 +105,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("Скачанный файл подозрительно мал — обновление прервано.");
@@ -125,26 +126,91 @@ 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, без интерполяции в команды оболочки.
+ // Этот же помощник проверяется на временных файлах интеграционными тестами.
+ internal static string BuildInstallScript(string currentExe, string downloadedExePath,
+ int processId, bool restart = true)
+ {
+ var config = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new
+ {
+ current = Path.GetFullPath(currentExe),
+ downloaded = Path.GetFullPath(downloadedExePath),
+ parent = processId,
+ restart,
+ })));
+ 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 {
+ # Подготавливаем полную копию на том же диске до изменения текущего 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 остаётся; резервная копия сохраняется.
+ [IO.File]::Replace($stage, $config.current, $backup)
+ $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 }
+ }
+ 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/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs
index 0d0ca81..a45a2b6 100644
--- a/src/SignService/ViewModels/MainWindowViewModel.cs
+++ b/src/SignService/ViewModels/MainWindowViewModel.cs
@@ -154,6 +154,8 @@ private async Task CheckUpdatesOnStartAsync()
[NotifyCanExecuteChangedFor(nameof(ExtractCommand))]
[NotifyCanExecuteChangedFor(nameof(MergeFilesCommand))]
[NotifyCanExecuteChangedFor(nameof(BuildContainerCommand))]
+ [NotifyCanExecuteChangedFor(nameof(SplitSignaturesCommand))]
+ [NotifyCanExecuteChangedFor(nameof(RemoveSignerCommand))]
private bool _isBusy;
[ObservableProperty]
@@ -204,6 +206,9 @@ private void Log(string message)
/// Запрос диалога выбора документа для сборки криптоконтейнера.
public event EventHandler? BuildContainerRequested;
+ public event EventHandler? SplitSignaturesRequested;
+ public event EventHandler? RemoveSignerRequested;
+
///
/// Запрос пароля у пользователя (заголовок, сообщение, предупреждение или null,
/// требуется ли повторный ввод). Возвращает пароль или null при отмене.
@@ -224,6 +229,9 @@ partial void OnSelectedCertificateChanged(CertificateItem? value)
return;
_settings.SignCertThumbprint = value.Thumbprint;
_settings.Save();
+ // Смена сертификата позволяет соподписать уже обработанные файлы.
+ foreach (var file in Files.Where(f => f.Status == SignStatus.Signed))
+ file.ResetForSigning();
}
partial void OnIsDetachedChanged(bool value)
@@ -670,6 +678,46 @@ public void AddFiles(params string[] filePaths)
[RelayCommand(CanExecute = nameof(CanBrowse))]
private void BuildContainer() => BuildContainerRequested?.Invoke(this, EventArgs.Empty);
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private void SplitSignatures() => SplitSignaturesRequested?.Invoke(this, EventArgs.Empty);
+
+ [RelayCommand(CanExecute = nameof(CanBrowse))]
+ private void RemoveSigner() => RemoveSignerRequested?.Invoke(this, EventArgs.Empty);
+
+ public async Task SplitSignatureFilesAsync(IReadOnlyList paths)
+ {
+ IsBusy = true;
+ try
+ {
+ foreach (var path in paths)
+ {
+ try
+ {
+ var result = await Task.Run(() => CmsExtractor.SplitSignatureFile(path));
+ StatusText = $"«{System.IO.Path.GetFileName(path)}»: создано отдельных подписей: {result.SignerFiles.Count}"
+ + (result.DocumentPath is null ? "." : $"; документ: {System.IO.Path.GetFileName(result.DocumentPath)}.");
+ }
+ catch (Exception e)
+ {
+ StatusText = $"«{System.IO.Path.GetFileName(path)}»: {e.Message}";
+ }
+ }
+ }
+ finally { IsBusy = false; }
+ }
+
+ public async Task RemoveSignerFromFileAsync(string path, string signerId)
+ {
+ IsBusy = true;
+ try
+ {
+ var result = await Task.Run(() => CmsExtractor.RemoveSignerFromFile(path, signerId));
+ StatusText = $"Подписант исключён → «{System.IO.Path.GetFileName(result.OutputPath)}», осталось подписантов: {result.SignerCount}. Исходный файл сохранён.";
+ }
+ catch (Exception e) { StatusText = "Не удалось исключить подписанта: " + e.Message; }
+ finally { IsBusy = false; }
+ }
+
///
/// Собирает прикреплённый криптоконтейнер (документ + имеющиеся подписи)
/// без создания своей подписи.
diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs
index 896428d..fc95fbe 100644
--- a/src/SignService/ViewModels/SignFileItem.cs
+++ b/src/SignService/ViewModels/SignFileItem.cs
@@ -80,9 +80,19 @@ public int AttachSignatures(IEnumerable sigPaths)
}
ExtraCount = _extraSignatures.Count;
+ 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..82e088b 100644
--- a/src/SignService/Views/MainWindow.axaml
+++ b/src/SignService/Views/MainWindow.axaml
@@ -222,6 +222,10 @@
Command="{Binding ExtractCommand}"/>
+
+