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}"/> + + await BrowseStampOnlyAsync(vm); vm.BuildContainerRequested += async (_, _) => await BrowseBuildContainerAsync(vm); vm.AddPoaRequested += async (_, _) => await BrowsePoaAsync(vm); + vm.SplitSignaturesRequested += async (_, _) => await BrowseSplitSignaturesAsync(vm); + vm.RemoveSignerRequested += async (_, _) => await BrowseRemoveSignerAsync(vm); vm.PropertyChanged += (_, args) => { // автопрокрутка лога вниз @@ -306,4 +308,40 @@ await vm.ExtractContainersAsync(files .Select(p => p!) .ToList()); } + + private async System.Threading.Tasks.Task BrowseSplitSignaturesAsync(MainWindowViewModel vm) + { + var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Групповые подписи для разделения по подписантам", + AllowMultiple = true, + FileTypeFilter = new[] { new FilePickerFileType("Подписи CMS") { Patterns = new[] { "*.sig", "*.p7s", "*.p7m" } } }, + }); + await vm.SplitSignatureFilesAsync(files.Select(f => f.TryGetLocalPath()).OfType().ToList()); + } + + private async System.Threading.Tasks.Task BrowseRemoveSignerAsync(MainWindowViewModel vm) + { + var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Подпись, из которой нужно исключить подписанта", + AllowMultiple = false, + FileTypeFilter = new[] { new FilePickerFileType("Подписи CMS") { Patterns = new[] { "*.sig", "*.p7s", "*.p7m" } } }, + }); + var path = files.FirstOrDefault()?.TryGetLocalPath(); + if (path is null) return; + try + { + var signers = await System.Threading.Tasks.Task.Run(() => SignService.Services.CmsExtractor.ListSigners(path)); + if (signers.Count < 2) + { + vm.StatusText = "Нельзя исключить последнего подписанта: в файле меньше двух подписантов."; + return; + } + var id = await new SignerDialog(signers).ShowDialog(this); + if (id is not null) + await vm.RemoveSignerFromFileAsync(path, id); + } + catch (System.Exception e) { vm.StatusText = "Не удалось прочитать подпись: " + e.Message; } + } } diff --git a/src/SignService/Views/SignerDialog.axaml b/src/SignService/Views/SignerDialog.axaml new file mode 100644 index 0000000..5929087 --- /dev/null +++ b/src/SignService/Views/SignerDialog.axaml @@ -0,0 +1,16 @@ + + + + + + +