diff --git a/CHANGELOG.md b/CHANGELOG.md index a6415bc..3548704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # История версий SignService +## v1.3.0 — 09.10.2026 + +- **Проверка ГОСТ-подписей КриптоПро.** Подписи, у которых в signatureAlgorithm + записаны параметры ГОСТ-ключа (так подписывают КриптоПро и CryptoAPI, в том + числе сама SignService на Windows), в 1.2.0 получали «не определено» + и при объединении помечались как непроверенные. Теперь они проверяются + полностью: значение подписи, привязка к документу, сертификат. +- Подпись, в подписанных атрибутах которой нет messageDigest, считается + не соответствующей документу. +- **Соподписание по содержимому документа.** При добавлении документа в его + папке в фоне ищутся .sig, .p7s и .p7m, относящиеся к нему по содержимому, — + даже если имя подписи другое («договор-.pdf .sig»). У каждого файла — выбор + «Добавить к „X.sig“ (8 подписантов)» или «Создать новую» и строка плана: + «будет добавлена подпись №9 к …». Переключатель «Добавить к существующей + подписи / Создать новую» задаёт выбор по умолчанию. +- Перед заменой существующего файла подписи без объединения и перед + исключением подписантов, чья подпись не соответствует документу, программа + спрашивает подтверждение и сохраняет рядом резервную копию + (`имя.ггггММдд-ЧЧммсс.bak`). +- После подписания видно число подписантов: «Подписан: 9 (8 + ваша) → X.sig»; + после смены сертификата подпись дописывается в получившийся файл. +- Подпись, перетащенная в окно из другой папки, прикладывается к своему + документу по содержимому и объединяется с вашей; сам файл не меняется. + Подписи, не подошедшие ни к одному документу, перечисляются в логе. +- «Объединить .sig» называет результат «документ.sig», если документ известен + (в том числе найден по содержимому), и не затирает существующий файл. +- Режим «подписывать копию со штампом» не затрагивает подписи оригинала. +- Соподписание по содержимому и исправление проверки ГОСТ основаны на работе + в форке sozertsatel/SignService. + ## v1.2.0 — 07.10.2026 - **Переработан интерфейс**: лог операций всегда виден в главном окне diff --git a/README.en.md b/README.en.md index 6badad7..6c24f98 100644 --- a/README.en.md +++ b/README.en.md @@ -38,13 +38,22 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas ### Co-signing and verification - other people's signatures are **merged with yours into a single `.sig`** - with multiple signers: automatically with an existing `name.sig` next to the - document (can be turned off), via the "+.sig" button, or by dropping - a signature file onto the window; + with multiple signers: the app finds a document's signatures in its folder + **by content** — the signature file name may be anything + ("договор-.pdf .sig", `.p7s`, `.p7m`); each file offers "Add to 'X.sig' + (8 signers)" or "Create new", and a plan line shows what will happen + ("signature No. 9 will be added to …"); the "Add to existing signature / + Create new" switch sets the default. Signatures from other folders are + attached with the "+.sig" button or by dropping them — to their document + by content; +- before replacing a signature file without merging and before excluding + signers the app asks for confirmation and keeps a backup next to it; the + result shows in the file row: "Подписан: 9 (8 + ваша) → X.sig"; - re-signing with the same certificate **replaces** your previous signature instead of duplicating it; - every merged signature is **cryptographically verified**, including the - document digest, signature value and CAdES certificate binding. Damaged + document digest, signature value and CAdES certificate binding — CryptoPro + signatures with GOST parameters in signatureAlgorithm included. Damaged signatures and signatures over another revision are excluded; signers that cannot be verified (unknown algorithm, missing certificate) are kept and explicitly reported; @@ -66,6 +75,8 @@ The operations are available in the **Tools** menu (pick files) and in the 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); + when the document is known (by name or found by content) the result is + `document.sig`, an existing file is never overwritten; - **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); @@ -206,8 +217,10 @@ 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 PDF stamp - (a dialog asks for its parameters); the timestamp is set up in **Settings…**. +2. Pick a certificate, the signature mode and "Add to existing signature" or + "Create new"; each file shows which signature yours will be added to. + 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 and in the @@ -236,6 +249,7 @@ src/SignService/ │ ├── 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, split, container +│ ├── SignatureDiscovery.cs # finding a document's signatures by content │ ├── 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 diff --git a/README.md b/README.md index 01dd046..e5023f5 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,21 @@ ### Соподписание и проверка -- подписи других лиц **объединяются с вашей в один `.sig`**: автоматически - с существующей `имя.sig` рядом (отключается), кнопкой «+.sig» у файла - или перетаскиванием файла подписи в окно; +- подписи других лиц **объединяются с вашей в один `.sig`**: программа сама + находит подписи документа в его папке **по содержимому** — имя файла подписи + может быть любым («договор-.pdf .sig», `.p7s`, `.p7m`); у каждого файла + выбирается «Добавить к „X.sig“ (8 подписантов)» или «Создать новую», а строка + плана показывает, что произойдёт («будет добавлена подпись №9 к …»); + переключатель «Добавить к существующей подписи / Создать новую» задаёт выбор + по умолчанию. Подписи из других папок прикладываются кнопкой «+.sig» или + перетаскиванием — к своему документу по содержимому; +- перед заменой файла подписи без объединения и перед исключением подписантов + программа спрашивает подтверждение и сохраняет резервную копию рядом; + итог виден в строке файла: «Подписан: 9 (8 + ваша) → X.sig»; - повторная подпись тем же сертификатом **заменяет** свою старую, а не дублирует; - каждая объединяемая подпись **проверяется криптографически**: хеш документа, - значение подписи и привязка сертификата CAdES. Повреждённые подписи и подписи + значение подписи и привязка сертификата CAdES — в том числе подписи КриптоПро + с параметрами ГОСТ в signatureAlgorithm. Повреждённые подписи и подписи под другой версией исключаются с указанием подписанта; подписи, которые проверить не удалось (неизвестный алгоритм, нет сертификата), сохраняются и явно отмечаются; - приложенная подпись другого лица или смена сертификата возвращают уже подписанный @@ -58,6 +67,8 @@ сразу. Отчёт можно скопировать или сохранить в TXT; - **Объединить .sig…** — несколько файлов подписей склеиваются в один со всеми подписантами (принимаются и прикреплённые контейнеры — документ сохраняется); + если документ известен (по имени или найден по содержимому), результат — + «документ.sig», существующий файл не затирается; - **Извлечь из .sig…** — из криптоконтейнера достаются: вложенный документ (байт-в-байт), откреплённая подпись со всеми подписантами и отдельные `.sig` по каждому подписанту (в имени — ФИО из сертификата); @@ -196,8 +207,10 @@ dotnet publish src/SignService -c Release -r win-x64 --self-contained true \ ## Как пользоваться 1. Запустите программу — в списке появятся сертификаты с закрытым ключом. -2. Выберите сертификат и режим подписи; при необходимости включите штамп - на PDF (параметры спросит диалог), штамп времени — в «Настройках…». +2. Выберите сертификат, режим подписи и «Добавить к существующей подписи» или + «Создать новую»; у каждого файла видно, к какой подписи добавится ваша. + При необходимости включите штамп на PDF (параметры спросит диалог), + штамп времени — в «Настройках…». 3. Перетащите файлы в окно (или «Обзор…») и нажмите **«Подписать»** — рядом с каждым файлом появится `имя.sig`. 4. Операции с готовыми подписями — в меню **«Инструменты»** и в контекстном @@ -225,6 +238,7 @@ src/SignService/ │ ├── Streebog.cs # ГОСТ Р 34.11-2012 для certHash (векторы RFC 6986) │ ├── CmsMerger.cs # объединение/разбор/сборка подписей на ASN.1 │ ├── CmsExtractor.cs # файловые операции: извлечение, объединение, разделение +│ ├── SignatureDiscovery.cs # поиск подписей документа по содержимому │ ├── SignatureVerifier.cs # проверка подписантов (BouncyCastle, ГОСТ без CSP) │ ├── CertificateValidator.cs # цепочка доверия (PKIX) и хранилища ОС │ ├── RevocationChecker.cs # отзыв сертификатов: CRL и OCSP diff --git a/src/SignService/Services/CmsExtractor.cs b/src/SignService/Services/CmsExtractor.cs index 9c6c51c..608c683 100644 --- a/src/SignService/Services/CmsExtractor.cs +++ b/src/SignService/Services/CmsExtractor.cs @@ -80,9 +80,11 @@ public static MergeFilesResult MergeSignatureFiles(IReadOnlyList paths) var inputs = paths.Select(File.ReadAllBytes).ToList(); - // Документ: из прикреплённого контейнера либо из файла рядом с .sig. + // Документ: из прикреплённого контейнера, из файла «имя.sig» → «имя» + // либо — если имя подписи нестандартное — по хешу содержимого в том же каталоге. byte[]? document = null; string documentNote = ""; + string? knownDocumentName = null; foreach (var input in inputs) { var content = CmsMerger.ExtractContent(input); @@ -98,15 +100,25 @@ public static MergeFilesResult MergeSignatureFiles(IReadOnlyList paths) { foreach (var path in paths) { - if (!path.EndsWith(".sig", StringComparison.OrdinalIgnoreCase)) + var documentPath = SignatureDiscovery.SiblingDocumentPath(path); + if (documentPath is null || !File.Exists(documentPath)) continue; - var documentPath = path[..^4]; - if (File.Exists(documentPath)) - { - document = File.ReadAllBytes(documentPath); - documentNote = $"проверено по документу «{Path.GetFileName(documentPath)}»"; - break; - } + document = File.ReadAllBytes(documentPath); + knownDocumentName = Path.GetFileName(documentPath); + documentNote = $"проверено по документу «{knownDocumentName}»"; + break; + } + } + + var directory = Path.GetDirectoryName(paths[0]) ?? "."; + if (document is null) + { + var found = SignatureDiscovery.FindDocumentByContent(directory, paths); + if (found is not null) + { + document = File.ReadAllBytes(found); + knownDocumentName = Path.GetFileName(found); + documentNote = $"проверено по документу «{knownDocumentName}» (найден по хешу содержимого, не по имени файла подписи)"; } } @@ -128,7 +140,6 @@ public static MergeFilesResult MergeSignatureFiles(IReadOnlyList paths) var info = CmsMerger.Inspect(merged); - var directory = Path.GetDirectoryName(paths[0]) ?? "."; var firstName = Path.GetFileName(paths[0]); var baseName = firstName.EndsWith(".sig", StringComparison.OrdinalIgnoreCase) || firstName.EndsWith(".p7s", StringComparison.OrdinalIgnoreCase) @@ -136,7 +147,14 @@ public static MergeFilesResult MergeSignatureFiles(IReadOnlyList paths) ? firstName[..^4] : firstName; - var outputPath = UniquePath(directory, $"{baseName} (объединённая).sig", paths); + // Документ известен — результат называется «документ.sig», а не повторяет + // странное имя входного файла. Существующий файл с таким именем не затирается. + var desiredName = knownDocumentName is not null + ? knownDocumentName + ".sig" + : $"{baseName} (объединённая).sig"; + var outputPath = UniquePath(directory, desiredName, paths); + if (!string.Equals(Path.GetFileName(outputPath), desiredName, StringComparison.OrdinalIgnoreCase)) + documentNote += $" Существующий файл «{desiredName}» не перезаписан, результат записан в «{Path.GetFileName(outputPath)}»."; AtomicFile.Write(outputPath, merged); return new MergeFilesResult( diff --git a/src/SignService/Services/CmsMerger.cs b/src/SignService/Services/CmsMerger.cs index 78b9bd2..bb592ce 100644 --- a/src/SignService/Services/CmsMerger.cs +++ b/src/SignService/Services/CmsMerger.cs @@ -50,7 +50,8 @@ public sealed record MergeResult( /// true — результат прикреплённый (с этим документом), false — откреплённый, /// null — прикреплённый, если прикреплённым был хотя бы один вход. /// - public static MergeResult MergeForDocument(IReadOnlyList signatures, byte[] document, bool? attach = null) + public static MergeResult MergeForDocument(IReadOnlyList signatures, byte[] document, + bool? attach = null, bool throwIfEmpty = true) { if (signatures.Count == 0) throw new ArgumentException("Нет подписей для объединения.", nameof(signatures)); @@ -105,8 +106,12 @@ public static MergeResult MergeForDocument(IReadOnlyList signatures, byt } if (parsed.All(p => p.Signers.Count == 0)) + { + if (!throwIfEmpty) + return new MergeResult(Array.Empty(), 0, excluded.Distinct().ToList(), unverified.Distinct().ToList()); throw new InvalidOperationException( "Все объединяемые подписи повреждены или не соответствуют текущему содержимому документа."); + } if (attachOutput) { @@ -453,6 +458,105 @@ public static byte[] AddUnsignedAttribute(byte[] signature, string attrOid, byte return BuildMerged(new List { parsed }); } + /// + /// Копия подписи для проверки, в которой у подписантов ГОСТ из signatureAlgorithm + /// убраны параметры ключа, а OID ключа заменён на OID алгоритма подписи. Так пишут + /// КриптоПро и CryptoAPI (в том числе SignService на Windows), а BouncyCastle такие + /// подписи отклоняет. Поле signatureAlgorithm не входит в подписанные данные, поэтому + /// значение подписи, подписанные атрибуты, сертификаты и порядок подписантов + /// остаются прежними. null — менять нечего. Результат — только для проверки: + /// объединение и запись используют исходные байты подписантов. + /// + internal static byte[]? WithoutGostSignatureParameters(byte[] normalizedCms) + { + try + { + var contentInfo = new AsnReader(normalizedCms, AsnEncodingRules.BER).ReadSequence(); + if (contentInfo.ReadObjectIdentifier() != SignedDataOid) + return null; + var signedData = contentInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0)).ReadSequence(); + var elements = new List(); + while (signedData.HasData) + elements.Add(signedData.ReadEncodedValue().ToArray()); + if (elements.Count < 4) + return null; + + var signerInfos = new AsnReader(elements[^1], AsnEncodingRules.BER).ReadSetOf(); + var signers = new List(); + var changed = false; + while (signerInfos.HasData) + { + var der = signerInfos.ReadEncodedValue().ToArray(); + var rewritten = WithoutGostParameters(der); + changed |= rewritten is not null; + signers.Add(rewritten ?? der); + } + + if (!changed) + return null; + + // BER-запись: SET OF не пересортировывается, подписанты сохраняют номера. + var writer = new AsnWriter(AsnEncodingRules.BER); + using (writer.PushSequence()) + { + writer.WriteObjectIdentifier(SignedDataOid); + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0))) + using (writer.PushSequence()) + { + foreach (var element in elements.Take(elements.Count - 1)) + writer.WriteEncodedValue(element); + using (writer.PushSetOf()) + { + foreach (var signer in signers) + writer.WriteEncodedValue(signer); + } + } + } + + return writer.Encode(); + } + catch (Exception e) when (e is AsnContentException or InvalidOperationException) + { + return null; // разбор не удался — проверка сообщит о повреждении сама + } + } + + // signatureAlgorithm подписанта ГОСТ → OID алгоритма подписи без параметров; null — не ГОСТ + // или уже в нужном виде. Остальные элементы SignerInfo копируются байт в байт. + private static byte[]? WithoutGostParameters(byte[] signerInfoDer) + { + var elements = SignerElements(signerInfoDer); + var signatureIndex = elements.FindIndex(e => e.Tag == new Asn1Tag(UniversalTagNumber.OctetString)); + if (signatureIndex < 3 || elements[signatureIndex - 1].Tag != Asn1Tag.Sequence) + return null; + + var algorithm = new AsnReader(elements[signatureIndex - 1].Der, AsnEncodingRules.BER).ReadSequence(); + var oid = algorithm.ReadObjectIdentifier(); + var hasParameters = algorithm.HasData && algorithm.PeekTag() != Asn1Tag.Null; + var signatureOid = oid switch + { + "1.2.643.7.1.1.1.1" or "1.2.643.7.1.1.3.2" => "1.2.643.7.1.1.3.2", // ГОСТ Р 34.10-2012 (256) + "1.2.643.7.1.1.1.2" or "1.2.643.7.1.1.3.3" => "1.2.643.7.1.1.3.3", // ГОСТ Р 34.10-2012 (512) + "1.2.643.2.2.19" or "1.2.643.2.2.3" => "1.2.643.2.2.3", // ГОСТ Р 34.10-2001 + _ => null, + }; + if (signatureOid is null || (!hasParameters && oid == signatureOid)) + return null; + + var identifier = new AsnWriter(AsnEncodingRules.DER); + using (identifier.PushSequence()) + identifier.WriteObjectIdentifier(signatureOid); + + var writer = new AsnWriter(AsnEncodingRules.DER); + using (writer.PushSequence()) + { + for (var i = 0; i < elements.Count; i++) + writer.WriteEncodedValue(i == signatureIndex - 1 ? identifier.Encode() : elements[i].Der); + } + + return writer.Encode(); + } + // Элементы верхнего уровня SignerInfo в исходном порядке (тег + TLV). private static List<(Asn1Tag Tag, byte[] Der)> SignerElements(byte[] signerInfoDer) { diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs index ecc39dc..de9c126 100644 --- a/src/SignService/Services/DocumentSigner.cs +++ b/src/SignService/Services/DocumentSigner.cs @@ -31,6 +31,13 @@ public sealed record SignOptions /// Пути к .sig других подписантов для объединения. public IReadOnlyList ExtraSignatures { get; init; } = Array.Empty(); + /// + /// Существующий файл подписи, в который добавляется своя. Если задан, + /// результат записывается в него (а не в «документ.sig»): так сохраняется + /// файл с нестандартным именем, найденный по хешу документа. + /// + public string? ExistingSignaturePath { get; init; } + /// Добавлять ли штамп времени TSA (CAdES-T) в подпись. public bool Timestamp { get; init; } @@ -88,7 +95,10 @@ public readonly record struct SignFileResult( int SignerCount, IReadOnlyList ExcludedSigners, IReadOnlyList UnverifiedSigners, - string? StampedCopyPath = null); + string? StampedCopyPath = null, + int PreviousSignerCount = 0, + bool AppendedToExisting = false, + string? BackupPath = null); private readonly TimestampClient _timestampClient = new(); @@ -161,7 +171,16 @@ public async Task SignFileAsync( } var data = await File.ReadAllBytesAsync(targetPath, cancellationToken); - var signaturePath = targetPath + ".sig"; + var canonicalSignaturePath = targetPath + ".sig"; + // Найденный файл подписи относится к оригиналу; копия со штампом — другой документ. + var existingSignaturePath = string.Equals(targetPath, filePath, StringComparison.Ordinal) + ? options.ExistingSignaturePath + : null; + var signaturePath = options.MergeWithExisting + && !string.IsNullOrWhiteSpace(existingSignaturePath) + && File.Exists(existingSignaturePath) + ? existingSignaturePath + : canonicalSignaturePath; // Свою подпись создаём ДО чтения объединяемых файлов, чтобы ошибка // подписания не оставила .sig наполовину обработанным. @@ -179,13 +198,39 @@ public async Task SignFileAsync( own = CmsMerger.AddUnsignedAttribute(own, TimestampClient.TimeStampTokenOid, token); } - var inputs = new List(); - if (options.MergeWithExisting && File.Exists(signaturePath)) - inputs.Add(await File.ReadAllBytesAsync(signaturePath, cancellationToken)); + var inputPaths = new List(); + void AddInputPath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return; + if (inputPaths.Any(existing => string.Equals( + Path.GetFullPath(existing), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase))) + return; + inputPaths.Add(path); + } + + if (options.MergeWithExisting) + { + if (!string.IsNullOrWhiteSpace(existingSignaturePath)) + AddInputPath(existingSignaturePath); + else + AddInputPath(canonicalSignaturePath); + } + foreach (var path in options.ExtraSignatures) + AddInputPath(path); + + var inputs = new List(); + foreach (var path in inputPaths) inputs.Add(await File.ReadAllBytesAsync(path, cancellationToken)); inputs.Add(own); // своя — последней: при совпадении подписанта она побеждает + var appended = inputPaths.Any(path => string.Equals( + Path.GetFullPath(path), Path.GetFullPath(signaturePath), StringComparison.OrdinalIgnoreCase)); + var previousSignerCount = appended + ? CmsMerger.CountSigners(await File.ReadAllBytesAsync(signaturePath, cancellationToken)) + : 0; + int signerCount; IReadOnlyList excluded; IReadOnlyList unverified; @@ -212,6 +257,12 @@ public async Task SignFileAsync( if (!(await File.ReadAllBytesAsync(targetPath, cancellationToken)).AsSpan().SequenceEqual(data)) throw new IOException("Документ изменился во время подписания. Подпись не сохранена; повторите операцию."); if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath); + // Резервная копия — только когда прежний файл теряет данные: он заменяется без + // объединения или из него исключаются подписанты. Дописанная подпись сохраняет + // всех прежних подписантов байт в байт, копия тогда не нужна. + string? backupPath = null; + if (File.Exists(signaturePath) && (!appended || excluded.Count > 0)) + backupPath = SignatureBackup.Create(signaturePath); await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken); // Режим «копия отдельно»: штампованная копия БЕЗ подписи, со всеми @@ -239,7 +290,8 @@ public async Task SignFileAsync( } return new SignFileResult( - targetPath, signaturePath, signerCount, excluded, unverified, stampedCopyPath); + targetPath, signaturePath, signerCount, excluded, unverified, stampedCopyPath, + previousSignerCount, appended, backupPath); } /// diff --git a/src/SignService/Services/SignatureBackup.cs b/src/SignService/Services/SignatureBackup.cs new file mode 100644 index 0000000..0029d31 --- /dev/null +++ b/src/SignService/Services/SignatureBackup.cs @@ -0,0 +1,22 @@ +using System; +using System.IO; + +namespace SignService.Services; + +/// +/// Резервная копия файла подписи перед заменой без объединения или исключением +/// подписантов. Копия лежит рядом и не заканчивается на .sig, поэтому поиск +/// подписей её не подхватывает. +/// +internal static class SignatureBackup +{ + public static string Create(string path) + { + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var backup = path + "." + stamp + ".bak"; + for (var i = 1; File.Exists(backup); i++) + backup = path + "." + stamp + "-" + i + ".bak"; + File.Copy(path, backup, overwrite: false); + return backup; + } +} diff --git a/src/SignService/Services/SignatureDiscovery.cs b/src/SignService/Services/SignatureDiscovery.cs new file mode 100644 index 0000000..38dca80 --- /dev/null +++ b/src/SignService/Services/SignatureDiscovery.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Cms; + +namespace SignService.Services; + +/// +/// Поиск существующих подписей документа по содержимому, а не по имени файла: +/// «договор-.pdf .sig» рядом с «договор.pdf» тоже находится. Подписанты проверяются +/// той же криптографической проверкой, что и при объединении, поэтому прогноз +/// «кто будет исключён» совпадает с результатом подписания. +/// +public static class SignatureDiscovery +{ + /// Документы больше этого размера по содержимому не сопоставляются. + public const long MaxDocumentBytes = 200L * 1024 * 1024; + + /// + /// Файл подписи относительно документа: MatchingSigners — подписи этого документа, + /// MismatchedSignerNames — однозначно не соответствуют ему (при объединении будут + /// исключены), UnverifiedSignerNames — проверить не удалось (сохранятся с пометкой). + /// + public sealed record SignatureMatch( + string Path, + int SignerCount, + int MatchingSigners, + IReadOnlyList MismatchedSignerNames, + IReadOnlyList UnverifiedSignerNames); + + /// + /// Итог поиска для документа: подходящие подписи из его папки (и «документ.sig», + /// даже если подписанты в нём не совпали), число разных подписантов в них + /// и перетащенные файлы подписей, относящиеся к этому документу. + /// + public sealed record DocumentDiscovery( + string DocumentPath, + IReadOnlyList FolderMatches, + int FolderSignerCount, + IReadOnlyList DroppedMatches); + + public static bool IsSignatureFile(string path) + { + var extension = Path.GetExtension(path); + return extension.Equals(".sig", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".p7s", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".p7m", StringComparison.OrdinalIgnoreCase); + } + + public static bool SamePath(string left, string right) => + string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); + + /// «имя.sig» / «имя.p7s» → «имя». Для файла без расширения подписи — null. + public static string? SiblingDocumentPath(string signaturePath) => + IsSignatureFile(signaturePath) ? signaturePath[..^Path.GetExtension(signaturePath).Length] : null; + + /// Склонение: 1 подписант, 2 подписанта, 8 подписантов. + public static string SignerWord(int count) + { + var hundred = count % 100; + var ten = count % 10; + if (hundred is >= 11 and <= 14) + return "подписантов"; + if (ten == 1) + return "подписант"; + if (ten is >= 2 and <= 4) + return "подписанта"; + return "подписантов"; + } + + /// + /// Поиск для набора документов: подписи в их папках и перетащенные файлы подписей. + /// Каждый документ читается и хешируется один раз, каждый файл подписи читается + /// один раз на весь набор. Выполняется вне потока интерфейса. + /// + public static IReadOnlyList Discover(IReadOnlyList documentPaths, + IReadOnlyList droppedSignatures, CancellationToken cancellationToken = default) + { + var signatureBytes = new Dictionary(StringComparer.OrdinalIgnoreCase); + byte[]? ReadSignature(string path) + { + var key = Path.GetFullPath(path); + if (!signatureBytes.TryGetValue(key, out var bytes)) + { + try { bytes = File.ReadAllBytes(key); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { bytes = null; } + signatureBytes[key] = bytes; + } + + return bytes; + } + + var folders = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var documentPath in documentPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var directory = Path.GetDirectoryName(Path.GetFullPath(documentPath)) ?? "."; + if (!folders.TryGetValue(directory, out var candidates)) + { + candidates = Directory.Exists(directory) + ? Directory.EnumerateFiles(directory).Where(IsSignatureFile).ToList() + : Array.Empty(); + folders[directory] = candidates; + } + + var document = ReadDocument(documentPath); + var canonical = documentPath + ".sig"; + var folderMatches = new List(); + var dropped = new List(); + var folderBytes = new List(); + if (document is not null) + { + var digests = new DocumentDigests(document); + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + if (SamePath(candidate, documentPath) || ReadSignature(candidate) is not { } bytes) + continue; + var isCanonical = SamePath(candidate, canonical); + if (Match(candidate, bytes, digests, includeUnrelated: isCanonical) is { } match) + { + folderMatches.Add(match); + folderBytes.Add(bytes); + } + } + + foreach (var signature in droppedSignatures) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ReadSignature(signature) is { } bytes + && Match(signature, bytes, digests, includeUnrelated: false) is { MatchingSigners: > 0 } match) + dropped.Add(match); + } + } + else if (File.Exists(canonical) && ReadSignature(canonical) is { } canonicalBytes) + { + // Документ слишком велик для сопоставления — остаётся «документ.sig» как раньше. + var count = SafeCount(canonicalBytes); + folderMatches.Add(new SignatureMatch(canonical, count, 0, Array.Empty(), Array.Empty())); + folderBytes.Add(canonicalBytes); + } + + result.Add(new DocumentDiscovery(documentPath, Order(folderMatches, documentPath), + UnionSignerCount(folderBytes, folderMatches), dropped)); + } + + return result; + } + + /// + /// Документ в каталоге, хеш которого совпал с messageDigest подписей. + /// Нужен, когда имя .sig не равно «документ.sig». + /// + public static string? FindDocumentByContent(string directory, IReadOnlyList signaturePaths) + { + var needed = new List<(string Oid, byte[] Digest)>(); + foreach (var path in signaturePaths) + { + try + { + var cms = new CmsSignedData(CmsMerger.Normalize(File.ReadAllBytes(path))); + foreach (SignerInformation signer in cms.GetSignerInfos().GetSigners()) + { + if (!TryGetMessageDigest(signer, out var digest)) + continue; + var oid = signer.DigestAlgorithmID.Algorithm.Id; + if (needed.Any(item => item.Oid == oid && item.Digest.AsSpan().SequenceEqual(digest))) + continue; + needed.Add((oid, digest)); + } + } + catch (Exception) + { + // не CMS — такой файл не поможет найти документ + } + } + + if (needed.Count == 0 || !Directory.Exists(directory)) + return null; + + string? best = null; + var bestScore = 0; + foreach (var file in Directory.EnumerateFiles(directory)) + { + if (IsSignatureFile(file) || signaturePaths.Any(path => SamePath(path, file))) + continue; + if (ReadDocument(file) is not { } content) + continue; + + var digests = new DocumentDigests(content); + var score = needed.Count(item => digests.Get(item.Oid) is { } actual && actual.AsSpan().SequenceEqual(item.Digest)); + if (score > bestScore) + { + bestScore = score; + best = file; + } + } + + return best; + } + + // Подписи файла относительно документа. Без совпадения messageDigest хотя бы у одного + // подписанта файл считается чужим (null), кроме «документ.sig» (includeUnrelated). + private static SignatureMatch? Match(string path, byte[] raw, DocumentDigests digests, bool includeUnrelated) + { + byte[] normalized; + List signers; + try + { + normalized = CmsMerger.Normalize(raw); + signers = new CmsSignedData(normalized).GetSignerInfos().GetSigners().ToList(); + } + catch (Exception) + { + return null; + } + + if (signers.Count == 0) + return null; + // Быстрый отсев чужих файлов по messageDigest — без проверки значения подписи. + if (!includeUnrelated && !signers.Any(signer => DigestMatches(signer, digests))) + return null; + + IReadOnlyList names; + try { names = CmsMerger.GetSigners(raw).Select(signer => signer.Name).ToList(); } + catch (Exception) { names = Array.Empty(); } + + var verification = SignatureVerifier.VerifyWithDigests(normalized, digests, VerificationOptions.CryptographyOnly); + var matching = 0; + var mismatched = new List(); + var unverified = new List(); + for (var i = 0; i < verification.Signers.Count; i++) + { + var check = verification.Signers[i]; + var name = i < names.Count ? names[i] : "неизвестный подписант"; + if (check.CryptographicallyValid) + matching++; + else if (check.Document.State == VerificationState.Invalid || check.Signature.State == VerificationState.Invalid + || check.CertificateBinding.State == VerificationState.Invalid) + mismatched.Add(name); + else + unverified.Add(name); + } + + if (verification.Signers.Count == 0 && !includeUnrelated) + return null; + return new SignatureMatch(path, signers.Count, matching, mismatched, unverified); + } + + private static IReadOnlyList Order(List matches, string documentPath) => matches + .OrderByDescending(m => m.MatchingSigners) + .ThenByDescending(m => m.SignerCount) + .ThenByDescending(m => SamePath(m.Path, documentPath + ".sig")) + .ThenBy(m => m.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Сколько разных подписантов окажется в объединении найденных файлов (до исключений). + private static int UnionSignerCount(List signatures, List matches) + { + if (signatures.Count == 0) + return 0; + if (signatures.Count == 1) + return matches[0].SignerCount; + try + { + return CmsMerger.CountSigners(CmsMerger.Merge(signatures)); + } + catch (Exception) + { + return matches.Max(m => m.SignerCount); + } + } + + private static int SafeCount(byte[] signature) + { + try { return CmsMerger.CountSigners(signature); } + catch (Exception) { return 0; } + } + + private static byte[]? ReadDocument(string path) + { + try + { + var info = new FileInfo(path); + if (!info.Exists || info.Length == 0 || info.Length > MaxDocumentBytes) + return null; + return File.ReadAllBytes(path); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static bool DigestMatches(SignerInformation signer, DocumentDigests digests) + { + if (!TryGetMessageDigest(signer, out var digest)) + return false; + var actual = digests.Get(signer.DigestAlgorithmID.Algorithm.Id); + return actual is not null && actual.AsSpan().SequenceEqual(digest); + } + + private static bool TryGetMessageDigest(SignerInformation signer, out byte[] digest) + { + digest = Array.Empty(); + try + { + var attribute = signer.SignedAttributes?[PkcsObjectIdentifiers.Pkcs9AtMessageDigest]; + if (attribute is null || attribute.AttrValues.Count != 1) + return false; + digest = Asn1OctetString.GetInstance(attribute.AttrValues[0]).GetOctets(); + return digest.Length > 0; + } + catch (Exception) + { + return false; + } + } +} diff --git a/src/SignService/Services/SignatureVerifier.cs b/src/SignService/Services/SignatureVerifier.cs index 4c7f384..22be052 100644 --- a/src/SignService/Services/SignatureVerifier.cs +++ b/src/SignService/Services/SignatureVerifier.cs @@ -46,7 +46,12 @@ private static SignatureVerificationResult VerifyCore(byte[] signature, byte[]? try { cancellationToken.ThrowIfCancellationRequested(); + // КриптоПро и CryptoAPI записывают в signatureAlgorithm параметры ГОСТ-ключа, + // а BouncyCastle такие подписи не принимает. Проверяется копия без этих + // параметров: значение подписи и подписанные атрибуты не меняются, все + // проверки (messageDigest, content-type, значение подписи) выполняются полностью. var normalized = CmsMerger.Normalize(signature); + normalized = CmsMerger.WithoutGostSignatureParameters(normalized) ?? normalized; var original = new CmsSignedData(normalized); var attached = original.SignedContent is not null; var embedded = attached ? CmsMerger.ExtractContent(normalized) : null; @@ -164,10 +169,15 @@ private static VerificationCheck CheckDocument(SignerInformation signer, Documen { try { - var attr = signer.SignedAttributes?[PkcsObjectIdentifiers.Pkcs9AtMessageDigest]; - if (attr is null) + var signedAttributes = signer.SignedAttributes; + if (signedAttributes is null) return VerificationCheck.Valid("Документ проверяется непосредственно значением подписи (без signedAttrs)."); - if (attr.AttrValues.Count != 1) + // С подписанными атрибутами документ связан с подписью только через messageDigest. + var digestAttributes = signedAttributes.GetAll(PkcsObjectIdentifiers.Pkcs9AtMessageDigest); + if (digestAttributes.Count == 0) + return VerificationCheck.Invalid("В подписанных атрибутах нет messageDigest: подпись не связана с содержимым документа."); + var attr = CmsAttribute.GetInstance(digestAttributes[0]); + if (digestAttributes.Count != 1 || attr.AttrValues.Count != 1) return VerificationCheck.Invalid("Некорректный атрибут messageDigest."); var digest = Asn1OctetString.GetInstance(attr.AttrValues[0]).GetOctets(); var actual = document.Get(signer.DigestAlgorithmID.Algorithm.Id); diff --git a/src/SignService/SignService.csproj b/src/SignService/SignService.csproj index 00745ab..d326609 100644 --- a/src/SignService/SignService.csproj +++ b/src/SignService/SignService.csproj @@ -10,7 +10,7 @@ true SignService — подписание документов ЭЦП SignService - 1.2.0 + 1.3.0 Зайнуллин Марат Илгамович Зайнуллин Марат Илгамович © 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com diff --git a/src/SignService/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs index 4fa3536..3961fe6 100644 --- a/src/SignService/ViewModels/MainWindowViewModel.cs +++ b/src/SignService/ViewModels/MainWindowViewModel.cs @@ -292,10 +292,14 @@ partial void OnIsDetachedChanged(bool value) partial void OnMergeWithExistingChanged(bool value) { - if (_initializing) - return; - _settings.MergeWithExisting = value; - _settings.Save(); + if (!_initializing) + { + _settings.MergeWithExisting = value; + _settings.Save(); + } + + foreach (var file in Files) + file.ApplyPreferredMode(value, resetPin: !_initializing); } partial void OnUseTimestampChanged(bool value) @@ -314,11 +318,21 @@ partial void OnTsaUrlChanged(string value) partial void OnUseStampChanged(bool value) { + UpdateStampedCopyMode(); if (_initializing) return; _settings.UseStamp = value; _settings.Save(); } + // В режиме «подписывать копию со штампом» найденные подписи оригинала к подписи не относятся. + private bool SignsStampedCopy(SignFileItem file) => UseStamp && StampSignCopy && PdfStamper.IsPdf(file.FilePath); + + private void UpdateStampedCopyMode() + { + foreach (var file in Files) + file.SignsStampedCopy = SignsStampedCopy(file); + } + partial void OnStampWithDateChanged(bool value) { if (_initializing) return; @@ -328,6 +342,7 @@ partial void OnStampWithDateChanged(bool value) partial void OnStampSignCopyChanged(bool value) { + UpdateStampedCopyMode(); if (_initializing) return; _settings.StampSignCopy = value; _settings.Save(); @@ -694,49 +709,163 @@ private async Task RemoveFromStoreAsync() private bool CanBrowse() => !IsBusy; + /// Добавляет файлы в очередь; подписи документов ищутся в фоне (см. ). + public void AddFiles(params string[] filePaths) => _ = AddFilesAsync(filePaths); + /// - /// Добавляет файлы в очередь. Файл подписи .sig прикладывается к своему - /// документу для объединения, если документ уже в списке (по имени: "документ.pdf.sig" - /// → "документ.pdf"); иначе пропускается. + /// Добавляет файлы в очередь. Для документов в фоне ищутся подписи в их папке + /// по содержимому (имя .sig может не совпадать с документом). Перетащенный файл + /// подписи прикладывается к документам очереди, к которым относится по содержимому, + /// иначе — к документу с именем «документ» для «документ.sig»; несопоставленные + /// перечисляются в статусе. /// - public void AddFiles(params string[] filePaths) + public async Task AddFilesAsync(params string[] filePaths) { - int added = 0, attached = 0, skippedSigs = 0; + var signatures = new List(); + var added = new List(); foreach (var path in filePaths) { if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path)) continue; - if (path.EndsWith(".sig", StringComparison.OrdinalIgnoreCase)) + if (SignatureDiscovery.IsSignatureFile(path)) { - var documentPath = path[..^4]; - var target = Files.FirstOrDefault(f => - string.Equals(f.FilePath, documentPath, StringComparison.OrdinalIgnoreCase)); - if (target is not null) - attached += target.AttachSignatures(new[] { path }); - else - skippedSigs++; + if (!signatures.Any(existing => SignatureDiscovery.SamePath(existing, path))) + signatures.Add(path); continue; } - if (Files.Any(f => string.Equals(f.FilePath, path, StringComparison.OrdinalIgnoreCase))) + if (Files.Any(file => SignatureDiscovery.SamePath(file.FilePath, path))) continue; - Files.Add(new SignFileItem(path) { Owner = this }); - added++; + var item = new SignFileItem(path) { Owner = this }; + item.SignsStampedCopy = SignsStampedCopy(item); + Files.Add(item); + added.Add(item); + } + + if (added.Count > 0) + StatusText = $"Добавлено файлов: {added.Count}. Всего в очереди: {Files.Count}"; + + // Новые документы ищут подписи в своей папке; перетащенные подписи сверяются со всей + // очередью. Во время подписания план уже стоявших файлов не меняется. + var targets = signatures.Count > 0 ? Files.ToList() : added; + var refresh = IsBusy ? added : targets; + var result = await DiscoverAsync(targets, signatures, refresh); + + var unmatched = new List(); + var attached = result.Attached; + foreach (var path in signatures.Where(path => !result.MatchedSignatures.Contains(System.IO.Path.GetFullPath(path)))) + { + var sibling = SignatureDiscovery.SiblingDocumentPath(path); + var named = sibling is null ? null : Files.FirstOrDefault(file => SignatureDiscovery.SamePath(file.FilePath, sibling)); + if (named is not null) + attached += named.AttachSignatures(new[] { path }); + else + unmatched.Add(System.IO.Path.GetFileName(path)); } var parts = new List(); - if (added > 0) parts.Add($"добавлено файлов: {added}"); - if (attached > 0) parts.Add($"приложено подписей: {attached}"); - if (skippedSigs > 0) parts.Add($"пропущено .sig без документа в списке: {skippedSigs}"); + if (result.Found > 0) + parts.Add($"найдены подписи в папке для документов: {result.Found}"); + if (attached > 0) + parts.Add($"приложено подписей: {attached}"); + if (unmatched.Count > 0) + parts.Add("не сопоставлены с документами в очереди: " + + string.Join(", ", unmatched.Select(name => "«" + name + "»"))); if (parts.Count > 0) { var summary = string.Join(", ", parts); - StatusText = char.ToUpper(summary[0]) + summary[1..] + $". Всего в очереди: {Files.Count}"; + StatusText = char.ToUpper(summary[0]) + summary[1..] + "."; } } + private sealed record DiscoveryOutcome(int Found, int Attached, ISet MatchedSignatures); + + // Сколько фоновых поисков подписей идёт: подписание ждёт их завершения. + private int _discoveryRuns; + + /// Идёт поиск подписей для документов очереди. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SignAllCommand))] + private bool _isDiscovering; + + partial void OnIsBusyChanged(bool value) + { + foreach (var file in Files) + file.NotifyBusyChanged(); + } + + // Поиск подписей в фоне: каждый документ читается и хешируется один раз. Варианты + // соподписания обновляются в потоке интерфейса только у файлов из refresh. + private async Task DiscoverAsync(IReadOnlyList targets, + IReadOnlyList droppedSignatures, IReadOnlyCollection refresh) + { + var matched = new HashSet(StringComparer.OrdinalIgnoreCase); + if (targets.Count == 0) + return new DiscoveryOutcome(0, 0, matched); + + var fresh = refresh.Where(item => !item.IsDiscovering && targets.Contains(item)).ToList(); + foreach (var item in fresh) + item.IsDiscovering = true; + _discoveryRuns++; + IsDiscovering = true; + try + { + var documents = targets.Select(item => item.FilePath).ToList(); + IReadOnlyList found; + try + { + found = await Task.Run(() => SignatureDiscovery.Discover(documents, droppedSignatures)); + } + catch (Exception ex) + { + Log("Поиск подписей не выполнен: " + ex.Message); + return new DiscoveryOutcome(0, 0, matched); + } + + var withMatches = 0; + var attached = 0; + for (var i = 0; i < targets.Count && i < found.Count; i++) + { + var item = targets[i]; + var discovery = found[i]; + if (fresh.Contains(item)) + { + item.ApplyDiscovery(discovery, MergeWithExisting); + if (discovery.FolderMatches.Any(match => match.MatchingSigners > 0)) + withMatches++; + } + + var dropped = discovery.DroppedMatches.Select(match => match.Path).ToList(); + if (dropped.Count == 0) + continue; + attached += item.AttachSignatures(dropped); + foreach (var path in dropped) + matched.Add(System.IO.Path.GetFullPath(path)); + } + + return new DiscoveryOutcome(withMatches, attached, matched); + } + finally + { + foreach (var item in fresh) + item.IsDiscovering = false; + if (--_discoveryRuns == 0) + IsDiscovering = false; + } + } + + /// Подписи, выбранные кнопкой «+.sig»: приложить к файлу (будут объединены с вашей). + public void AttachSignatureFiles(SignFileItem item, IReadOnlyList paths) + { + var added = item.AttachSignatures(paths); + if (added > 0) + StatusText = $"Приложено подписей к «{item.FileName}»: {added} (всего: {item.ExtraCount})"; + else if (paths.Count > 0) + StatusText = $"К «{item.FileName}» не добавлено новых подписей."; + } + /// Открыть диалог выбора подписей других лиц для файла. [RelayCommand(CanExecute = nameof(CanBrowse))] private void AttachSignatures(SignFileItem item) => AttachSignaturesRequested?.Invoke(this, item); @@ -821,7 +950,8 @@ public void SaveStampOptions(Views.StampOptionsDialog.Result result) /// есть «имя.sig», плашки ставятся для ВСЕХ его подписантов; иначе — /// по данным выбранного сертификата. /// - public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfStamper.StampOptions options) + public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfStamper.StampOptions options, + string? signatureHint = null) { if (pdfPaths.Count == 0 || SelectedCertificate is not { } item) return; @@ -843,7 +973,7 @@ public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfSt var stamped = await Task.Run(() => { - var sigPath = path + ".sig"; + var sigPath = signatureHint ?? path + ".sig"; var certs = System.IO.File.Exists(sigPath) ? CmsMerger.GetSignerCertificates(System.IO.File.ReadAllBytes(sigPath)) : Array.Empty(); @@ -879,10 +1009,10 @@ public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfSt [RelayCommand] private async Task InspectSignaturesAsync(SignFileItem item) { - var sigPath = item.FilePath + ".sig"; + var sigPath = item.SignaturePathForTools(); if (!System.IO.File.Exists(sigPath)) { - StatusText = $"Рядом с «{item.FileName}» нет файла подписи «{System.IO.Path.GetFileName(sigPath)}»."; + StatusText = $"Для «{item.FileName}» не найден файл подписи (ни «{System.IO.Path.GetFileName(item.FilePath)}.sig», ни подпись по хешу документа)."; return; } @@ -899,17 +1029,19 @@ private async Task StampItemAsync(SignFileItem item) if (result is null) return; SaveStampOptions(result); - await StampWithoutSigningAsync(new[] { item.FilePath }, result.Options); + var signature = item.SignaturePathForTools(); + await StampWithoutSigningAsync(new[] { item.FilePath }, result.Options, + System.IO.File.Exists(signature) ? signature : null); } /// ПКМ: извлечь из контейнера «имя.sig» рядом с файлом. [RelayCommand(CanExecute = nameof(CanBrowse))] private async Task ExtractItemAsync(SignFileItem item) { - var sigPath = item.FilePath + ".sig"; + var sigPath = item.SignaturePathForTools(); if (!System.IO.File.Exists(sigPath)) { - StatusText = $"Рядом с «{item.FileName}» нет файла подписи для извлечения."; + StatusText = $"Для «{item.FileName}» нет файла подписи для извлечения."; return; } @@ -918,17 +1050,24 @@ private async Task ExtractItemAsync(SignFileItem item) /// ПКМ: собрать криптоконтейнер из файла и его подписи рядом. [RelayCommand(CanExecute = nameof(CanBrowse))] - private Task BuildContainerItemAsync(SignFileItem item) => - BuildContainerAsync(item.FilePath, null); + private Task BuildContainerItemAsync(SignFileItem item) + { + var signatures = item.DiscoveredMatches.Select(match => match.Path).ToList(); + var canonical = item.FilePath + ".sig"; + if (System.IO.File.Exists(canonical) + && signatures.All(path => !SignatureDiscovery.SamePath(path, canonical))) + signatures.Insert(0, canonical); + return BuildContainerAsync(item.FilePath, signatures.Count > 0 ? signatures : null); + } /// ПКМ: разделить групповую подпись «имя.sig» рядом с файлом по подписантам. [RelayCommand(CanExecute = nameof(CanBrowse))] private async Task SplitItemAsync(SignFileItem item) { - var sigPath = item.FilePath + ".sig"; + var sigPath = item.SignaturePathForTools(); if (!System.IO.File.Exists(sigPath)) { - StatusText = $"Рядом с «{item.FileName}» нет файла подписи для разделения."; + StatusText = $"Для «{item.FileName}» нет файла подписи для разделения."; return; } @@ -939,10 +1078,10 @@ private async Task SplitItemAsync(SignFileItem item) [RelayCommand(CanExecute = nameof(CanBrowse))] private async Task RemoveSignerItemAsync(SignFileItem item) { - var sigPath = item.FilePath + ".sig"; + var sigPath = item.SignaturePathForTools(); if (!System.IO.File.Exists(sigPath)) { - StatusText = $"Рядом с «{item.FileName}» нет файла подписи."; + StatusText = $"Для «{item.FileName}» нет файла подписи."; return; } @@ -1138,7 +1277,70 @@ private void Clear() private bool CanClear() => !IsBusy; private bool CanSignAll() => - !IsBusy && SelectedCertificate is not null && Files.Count > 0; + !IsBusy && !IsDiscovering && SelectedCertificate is not null && Files.Count > 0; + + /// + /// Текст подтверждения перед подписанием: новый файл при уже найденных подписях, + /// перезапись «документ.sig» без объединения, исключение подписантов. + /// null — подтверждение не требуется. + /// + internal string? DescribeSignRisks(IReadOnlyList files) + { + var blocks = new List(); + foreach (var file in files) + { + // Копия со штампом подписывается отдельно: найденные подписи оригинала не затрагиваются. + if (file.SignsStampedCopy) + continue; + var createNew = file.SelectedCoSignOption is not { CreateNew: false }; + if (createNew) + { + var canonical = file.FilePath + ".sig"; + var others = file.DiscoveredMatches + .Where(match => !SignatureDiscovery.SamePath(match.Path, canonical)) + .ToList(); + if (others.Count > 0) + { + blocks.Add($"«{file.FileName}»: в папке уже есть подходящая подпись " + + string.Join(", ", others.Select(match => + $"«{System.IO.Path.GetFileName(match.Path)}» ({match.SignerCount} {SignatureDiscovery.SignerWord(match.SignerCount)})")) + + ". Будет создан новый файл «" + System.IO.Path.GetFileName(canonical) + + "», эти подписи не изменятся и не будут объединены."); + } + + if (System.IO.File.Exists(canonical)) + { + blocks.Add($"«{file.FileName}»: файл «{System.IO.Path.GetFileName(canonical)}» уже существует " + + "и будет перезаписан без объединения. Подписанты из него не сохранятся в новом файле " + + "(перед заменой создаётся резервная копия)."); + } + } + + var inputs = file.PlannedMergeInputs(); + if (inputs.Count == 0) + continue; + try + { + var document = System.IO.File.ReadAllBytes(file.FilePath); + var signatures = inputs.Select(System.IO.File.ReadAllBytes).ToList(); + var preview = CmsMerger.MergeForDocument(signatures, document, attach: false, throwIfEmpty: false); + if (preview.ExcludedSigners.Count > 0) + { + blocks.Add($"«{file.FileName}»: при объединении будут исключены подписанты, " + + "чья подпись не соответствует документу: " + string.Join("; ", preview.ExcludedSigners) + + ". Они не попадут в файл подписи."); + } + } + catch (Exception e) + { + blocks.Add($"«{file.FileName}»: не удалось заранее проверить объединяемые подписи ({e.Message})."); + } + } + + if (blocks.Count == 0) + return null; + return string.Join("\n\n", blocks) + "\n\nПродолжить?"; + } [RelayCommand(CanExecute = nameof(CanSignAll))] private async Task SignAllAsync() @@ -1208,8 +1410,28 @@ private async Task SignAllAsync() if (poaPackage.Check.State == PowerOfAttorneyService.CheckState.Warning) Log("⚠ МЧД: " + poaPackage.Check.Message); } - foreach (var file in Files.Where(f => f.Status != SignStatus.Signed).ToList()) + + var pending = Files.Where(file => file.Status != SignStatus.Signed).ToList(); + var risks = await Task.Run(() => DescribeSignRisks(pending)); + if (risks is not null) + { + if (RequestConfirmAsync is null) + { + StatusText = "Подписание остановлено — нужно подтверждение. " + risks; + return; + } + + var confirmed = await RequestConfirmAsync("Подтверждение подписания", risks); + if (!confirmed) + { + StatusText = "Подписание отменено."; + return; + } + } + + foreach (var file in pending) { + file.Warning = null; file.Status = SignStatus.Signing; StatusText = $"Подписание: {file.FileName}"; @@ -1217,11 +1439,19 @@ private async Task SignAllAsync() { // Task.Run: подпись может блокировать (диалог PIN-кода CSP), // не держим UI-поток. + var inputs = file.PlannedMergeInputs(); + var existing = !file.SignsStampedCopy && file.SelectedCoSignOption is { CreateNew: false, SignaturePath: { } selected } + ? selected + : null; + var extras = inputs.Where(path => existing is null + || !SignatureDiscovery.SamePath(path, existing)).ToList(); var options = new DocumentSigner.SignOptions { Detached = IsDetached, - MergeWithExisting = MergeWithExisting, - ExtraSignatures = file.ExtraSignatures.ToList(), + // Копия со штампом объединяется только со своей прежней подписью, как раньше. + MergeWithExisting = existing is not null || (file.SignsStampedCopy && MergeWithExisting), + ExistingSignaturePath = existing, + ExtraSignatures = extras, Timestamp = UseTimestamp, TsaUrl = TsaUrl, Stamp = UseStamp, @@ -1236,23 +1466,25 @@ private async Task SignAllAsync() () => _documentSigner.SignFileAsync(file.FilePath, certificate, options)); file.SignaturePath = result.SignaturePath; file.SignerCount = result.SignerCount; + file.PreviousSignerCount = result.PreviousSignerCount; + file.AppendedToExisting = result.AppendedToExisting; - var notes = new List(); if (result.ExcludedSigners.Count > 0) - notes.Add("исключены не соответствующие документу подписи: " - + string.Join("; ", result.ExcludedSigners)); - if (result.UnverifiedSigners.Count > 0) - notes.Add("не удалось проверить: " + string.Join("; ", result.UnverifiedSigners)); - file.Message = notes.Count > 0 ? string.Join(". ", notes) : null; + file.Warning = "Внимание: исключены подписанты, подпись которых не соответствует документу: " + + string.Join("; ", result.ExcludedSigners); + file.Message = result.UnverifiedSigners.Count > 0 + ? "не удалось проверить: " + string.Join("; ", result.UnverifiedSigners) + : null; file.Status = SignStatus.Signed; signed++; - Log($" [OK] {file.FileName} → {System.IO.Path.GetFileName(result.SignaturePath)}" - + (result.SignerCount > 1 ? $" (подписантов: {result.SignerCount})" : "") + Log($" [OK] {file.FileName} → {file.StatusDisplay}" + + (result.BackupPath is null + ? "" + : $". Резервная копия: {System.IO.Path.GetFileName(result.BackupPath)}") + (result.StampedCopyPath is null ? "" - : $"; копия со штампом (без подписи): {System.IO.Path.GetFileName(result.StampedCopyPath)}") - + (file.Message is null ? "" : $". {file.Message}")); + : $"; копия со штампом (без подписи): {System.IO.Path.GetFileName(result.StampedCopyPath)}")); } catch (Exception ex) { @@ -1276,5 +1508,10 @@ private async Task SignAllAsync() StatusText = failed == 0 ? $"Готово. Подписано файлов: {signed}" : $"Подписано: {signed}, с ошибками: {failed}"; + + // Файлы подписи изменились: обновляем варианты, чтобы следующее соподписание + // (например, после смены сертификата) дописывало подпись в получившийся файл. + var processed = Files.Where(file => file.Status == SignStatus.Signed).ToList(); + await DiscoverAsync(processed, Array.Empty(), processed); } } diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs index c181bb5..085b012 100644 --- a/src/SignService/ViewModels/SignFileItem.cs +++ b/src/SignService/ViewModels/SignFileItem.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; +using SignService.Services; namespace SignService.ViewModels; @@ -14,11 +16,41 @@ public enum SignStatus Failed, } +/// Вариант в списке файла: добавить подпись в найденный .sig или создать новый. +public sealed class CoSignOption +{ + public CoSignOption(string? signaturePath, int signerCount) + { + SignaturePath = signaturePath; + SignerCount = signerCount; + Label = signaturePath is null + ? "Создать новую подпись" + : $"Добавить к «{Path.GetFileName(signaturePath)}» ({signerCount} {SignatureDiscovery.SignerWord(signerCount)})"; + } + + /// null — создать «документ.sig», не объединяя с найденными файлами. + public string? SignaturePath { get; } + + public int SignerCount { get; } + + public string Label { get; } + + public bool CreateNew => SignaturePath is null; + + public override string ToString() => Label; +} + /// /// Файл в очереди на подписание. /// public partial class SignFileItem : ObservableObject { + private readonly List _extraSignatures = new(); + private IReadOnlyList _discovered = Array.Empty(); + private bool _adjustingSelection; + private int _unionCount; + private string _planText = "будет создан новый файл подписи"; + public SignFileItem(string filePath) { FilePath = filePath; @@ -26,6 +58,10 @@ public SignFileItem(string filePath) var info = new FileInfo(filePath); SizeDisplay = info.Exists ? FormatSize(info.Length) : "—"; + CoSignOptions.Add(new CoSignOption(null, 0)); + _adjustingSelection = true; + SelectedCoSignOption = CoSignOptions[0]; + _adjustingSelection = false; } public string FilePath { get; } @@ -42,14 +78,24 @@ public SignFileItem(string filePath) [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusDisplay))] + [NotifyPropertyChangedFor(nameof(WarningDisplay))] + [NotifyPropertyChangedFor(nameof(HasWarning))] + [NotifyPropertyChangedFor(nameof(CanChooseCoSign))] private SignStatus _status = SignStatus.Pending; [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusDisplay))] private string? _message; + /// Предупреждение об исключённых при объединении подписантах — отдельной строкой. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WarningDisplay))] + [NotifyPropertyChangedFor(nameof(HasWarning))] + private string? _warning; + /// Путь к созданному файлу подписи (.sig). [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusDisplay))] private string? _signaturePath; /// Число подписантов в созданном .sig (после соподписания может быть больше 1). @@ -57,11 +103,50 @@ public SignFileItem(string filePath) [NotifyPropertyChangedFor(nameof(StatusDisplay))] private int _signerCount; - private readonly List _extraSignatures = new(); + /// Сколько подписантов было в файле, к которому добавили подпись. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusDisplay))] + private int _previousSignerCount; + + /// true — подпись дописана в уже существовавший файл, false — создан новый. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusDisplay))] + private bool _appendedToExisting; + + /// Идёт поиск подписей документа (в фоне, после добавления в очередь). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusDisplay))] + [NotifyPropertyChangedFor(nameof(CanChooseCoSign))] + private bool _isDiscovering; + + /// + /// Подписывается копия со штампом (режим «подписывать копию»): найденные подписи + /// оригинала к ней не относятся — объединяются только приложенные. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanChooseCoSign))] + private bool _signsStampedCopy; + + partial void OnSignsStampedCopyChanged(bool value) => UpdatePlan(); /// Приложенные подписи других лиц для объединения (пути к .sig). public IReadOnlyList ExtraSignatures => _extraSignatures; + /// Подписи из папки документа, относящиеся к нему (и «документ.sig»). + public IReadOnlyList DiscoveredMatches => _discovered; + + /// Пользователь сам выбрал режим для этого файла — общий переключатель его не сбрасывает, пока не переключат режим целиком. + public bool CoSignPinned { get; private set; } + + public ObservableCollection CoSignOptions { get; } = new(); + + [ObservableProperty] + private CoSignOption? _selectedCoSignOption; + + /// Режим можно менять, пока файл ждёт подписания и программа не занята. + public bool CanChooseCoSign => Status is SignStatus.Pending or SignStatus.Failed && !IsDiscovering && !SignsStampedCopy + && Owner?.IsBusy != true; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasExtra))] [NotifyPropertyChangedFor(nameof(ExtraDisplay))] @@ -71,6 +156,107 @@ public SignFileItem(string filePath) public string ExtraDisplay => $"+ подписей: {ExtraCount}"; + public bool HasWarning => !string.IsNullOrEmpty(WarningDisplay); + + public string? WarningDisplay => !string.IsNullOrEmpty(Warning) ? Warning : DiscoveryWarning(); + + /// Применяет результат поиска подписей (вызывается в потоке интерфейса). + public void ApplyDiscovery(SignatureDiscovery.DocumentDiscovery discovery, bool preferAdd) + { + _discovered = discovery.FolderMatches; + _unionCount = discovery.FolderSignerCount; + + var previous = SelectedCoSignOption?.SignaturePath; + // Выбор для уже выполненного подписания исчерпан: дальше — общий режим. + if (Status == SignStatus.Signed) + CoSignPinned = false; + var pinned = CoSignPinned; + _adjustingSelection = true; + CoSignOptions.Clear(); + foreach (var match in _discovered) + CoSignOptions.Add(new CoSignOption(match.Path, match.SignerCount)); + CoSignOptions.Add(new CoSignOption(null, 0)); + + if (pinned && previous is null) + SelectedCoSignOption = CoSignOptions.Last(option => option.CreateNew); + else if (pinned) + SelectedCoSignOption = CoSignOptions.FirstOrDefault(option => + option.SignaturePath is not null && SignatureDiscovery.SamePath(option.SignaturePath, previous!)) + ?? CoSignOptions.FirstOrDefault(option => !option.CreateNew) + ?? CoSignOptions.Last(); + else + SelectedCoSignOption = preferAdd + ? CoSignOptions.FirstOrDefault(option => !option.CreateNew) ?? CoSignOptions.Last() + : CoSignOptions.Last(option => option.CreateNew); + _adjustingSelection = false; + UpdatePlan(); + } + + /// Общий переключатель «добавить / создать новую» для файлов без ручного выбора. + public void ApplyPreferredMode(bool addToExisting, bool resetPin) + { + if (resetPin) + CoSignPinned = false; + else if (CoSignPinned) + return; + + _adjustingSelection = true; + SelectedCoSignOption = addToExisting + ? CoSignOptions.FirstOrDefault(option => !option.CreateNew) ?? CoSignOptions.LastOrDefault() + : CoSignOptions.LastOrDefault(option => option.CreateNew) ?? CoSignOptions.LastOrDefault(); + _adjustingSelection = false; + UpdatePlan(); + } + + /// Модель окна сообщает о смене занятости — от неё зависит выбор режима. + public void NotifyBusyChanged() => OnPropertyChanged(nameof(CanChooseCoSign)); + + /// + /// Файлы, которые войдут в объединение: в режиме «добавить» — выбранная подпись + /// и остальные найденные в папке; в любом режиме — явно приложенные подписи. + /// + public IReadOnlyList PlannedMergeInputs() + { + var list = new List(); + if (!SignsStampedCopy && SelectedCoSignOption is { CreateNew: false, SignaturePath: { } selected }) + { + list.Add(selected); + foreach (var match in _discovered) + { + if (!list.Any(path => SignatureDiscovery.SamePath(path, match.Path))) + list.Add(match.Path); + } + } + + foreach (var extra in _extraSignatures) + { + if (!list.Any(path => SignatureDiscovery.SamePath(path, extra))) + list.Add(extra); + } + + return list; + } + + /// Куда будет записан результат: выбранный .sig либо «документ.sig». + public string PlannedOutputPath => + !SignsStampedCopy && SelectedCoSignOption is { CreateNew: false, SignaturePath: { } path } + ? path + : FilePath + ".sig"; + + /// + /// Файл для проверки, извлечения и штампа: выбранный для соподписания, + /// иначе «документ.sig», иначе первая найденная по содержимому подпись. + /// + public string SignaturePathForTools() + { + if (SelectedCoSignOption is { CreateNew: false, SignaturePath: { } selected } && File.Exists(selected)) + return selected; + var canonical = FilePath + ".sig"; + if (File.Exists(canonical)) + return canonical; + return _discovered.FirstOrDefault(match => File.Exists(match.Path))?.Path ?? canonical; + } + /// Прикладывает файлы подписей других лиц (дубликаты пропускаются). public int AttachSignatures(IEnumerable sigPaths) { @@ -79,7 +265,7 @@ public int AttachSignatures(IEnumerable sigPaths) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) continue; - if (_extraSignatures.Any(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase))) + if (_extraSignatures.Any(existing => SignatureDiscovery.SamePath(existing, path))) continue; _extraSignatures.Add(path); added++; @@ -89,6 +275,7 @@ public int AttachSignatures(IEnumerable sigPaths) // Новая подпись другого лица должна попасть в .sig — файл снова ждёт подписания. if (added > 0 && Status == SignStatus.Signed) ResetForSigning(); + UpdatePlan(); return added; } @@ -97,23 +284,94 @@ public void ResetForSigning() { Status = SignStatus.Pending; Message = null; + Warning = null; SignaturePath = null; SignerCount = 0; + PreviousSignerCount = 0; + AppendedToExisting = false; + UpdatePlan(); } public string StatusDisplay => Status switch { - SignStatus.Pending => "Ожидает", + SignStatus.Pending when IsDiscovering => "поиск подписей документа…", + SignStatus.Pending => _planText, SignStatus.Signing => "Подписывается…", - SignStatus.Signed when SignerCount > 1 => - $"Подписан (подписантов: {SignerCount}) → {Path.GetFileName(SignaturePath)}{SignedNote}", - SignStatus.Signed => $"Подписан → {Path.GetFileName(SignaturePath)}{SignedNote}", + SignStatus.Signed when AppendedToExisting && SignerCount == PreviousSignerCount + 1 => + $"Подписан: {SignerCount} ({PreviousSignerCount} + ваша) → {FileNameOf(SignaturePath)}{SignedNote}", + SignStatus.Signed when AppendedToExisting => + $"Подписан: {SignerCount} (было {PreviousSignerCount}) → {FileNameOf(SignaturePath)}{SignedNote}", + SignStatus.Signed when SignerCount <= 1 => $"Подписан: 1, новый файл → {FileNameOf(SignaturePath)}{SignedNote}", + SignStatus.Signed => $"Подписан: {SignerCount}, новый файл → {FileNameOf(SignaturePath)}{SignedNote}", SignStatus.Failed => $"Ошибка: {Message}", _ => string.Empty, }; + partial void OnSelectedCoSignOptionChanged(CoSignOption? value) + { + if (!_adjustingSelection && value is not null) + CoSignPinned = true; + UpdatePlan(); + } + + private void UpdatePlan() + { + _planText = BuildPlan(); + OnPropertyChanged(nameof(StatusDisplay)); + OnPropertyChanged(nameof(WarningDisplay)); + OnPropertyChanged(nameof(HasWarning)); + } + + private string BuildPlan() + { + // Приложенные файлы, которые и так войдут как найденные в папке, отдельно не называем. + var planned = !SignsStampedCopy && SelectedCoSignOption is { CreateNew: false } + ? _discovered.Select(match => match.Path).ToList() + : new List(); + var outside = _extraSignatures.Count(extra => !planned.Any(path => SignatureDiscovery.SamePath(path, extra))); + var extras = outside > 0 ? $"; приложенные подписи ({outside}) будут объединены с вашей" : ""; + if (SignsStampedCopy) + return "будет подписана копия со штампом — для неё создаётся новый файл подписи" + extras; + if (SelectedCoSignOption is { CreateNew: false, SignaturePath: { } path }) + { + var others = _discovered.Where(match => !SignatureDiscovery.SamePath(match.Path, path)).ToList(); + var excluded = PlannedDiscovered().SelectMany(match => match.MismatchedSignerNames).Distinct().Count(); + var baseCount = Math.Max(0, (others.Count == 0 ? SelectedCoSignOption.SignerCount : _unionCount) - excluded); + var sentence = + $"будет добавлена подпись №{baseCount + 1} к «{Path.GetFileName(path)}» ({SelectedCoSignOption.SignerCount} {SignatureDiscovery.SignerWord(SelectedCoSignOption.SignerCount)})"; + if (others.Count > 0) + sentence += "; также объединятся: " + string.Join(", ", others.Select(match => "«" + Path.GetFileName(match.Path) + "»")); + return sentence + extras; + } + + var canonical = FilePath + ".sig"; + if (File.Exists(canonical)) + return "будет создан новый файл подписи — существующий «" + Path.GetFileName(canonical) + + "» будет заменён (сохранится резервная копия)" + extras; + return "будет создан новый файл подписи" + extras; + } + + private IEnumerable PlannedDiscovered() => + PlannedMergeInputs() + .Select(path => _discovered.FirstOrDefault(item => SignatureDiscovery.SamePath(item.Path, path))) + .OfType(); + + private string? DiscoveryWarning() + { + if (Status != SignStatus.Pending || IsDiscovering || SignsStampedCopy) + return null; + var names = PlannedDiscovered().SelectMany(match => match.MismatchedSignerNames).Distinct().ToList(); + if (names.Count == 0) + return null; + return "Внимание: при объединении будут исключены подписанты, чья подпись не соответствует документу: " + + string.Join("; ", names) + ". Перед исключением программа запросит подтверждение."; + } + private string SignedNote => string.IsNullOrEmpty(Message) ? string.Empty : $" ⚠ {Message}"; + private static string FileNameOf(string? path) => + string.IsNullOrEmpty(path) ? "" : Path.GetFileName(path); + private static string FormatSize(long bytes) => bytes switch { < 1024 => $"{bytes} Б", diff --git a/src/SignService/Views/ConfirmDialog.axaml b/src/SignService/Views/ConfirmDialog.axaml index 8cf1c33..3a0c256 100644 --- a/src/SignService/Views/ConfirmDialog.axaml +++ b/src/SignService/Views/ConfirmDialog.axaml @@ -1,13 +1,15 @@ - + + + + - - - + + + + + + + + - - + diff --git a/src/SignService/Views/MainWindow.axaml.cs b/src/SignService/Views/MainWindow.axaml.cs index cb9a78a..04aa79c 100644 --- a/src/SignService/Views/MainWindow.axaml.cs +++ b/src/SignService/Views/MainWindow.axaml.cs @@ -152,14 +152,13 @@ private async System.Threading.Tasks.Task BrowseSignaturesAsync(SignFileItem ite }, }); - var added = item.AttachSignatures(files + var paths = files .Select(f => f.TryGetLocalPath()) .Where(p => p is not null) .Select(p => p!) - .ToList()); - - if (added > 0 && DataContext is MainWindowViewModel vm) - vm.StatusText = $"Приложено подписей к «{item.FileName}»: {added} (всего: {item.ExtraCount})"; + .ToList(); + if (DataContext is MainWindowViewModel vm) + vm.AttachSignatureFiles(item, paths); } private async System.Threading.Tasks.Task BrowseLogoAsync(MainWindowViewModel vm) diff --git a/tests/SignService.Tests/CoSignTests.cs b/tests/SignService.Tests/CoSignTests.cs new file mode 100644 index 0000000..686410b --- /dev/null +++ b/tests/SignService.Tests/CoSignTests.cs @@ -0,0 +1,564 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Asn1; +using Org.BouncyCastle.Asn1.CryptoPro; +using Org.BouncyCastle.Asn1.Rosstandart; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Cms; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; +using SignService.Services; +using SignService.ViewModels; +using BcCertificate = Org.BouncyCastle.X509.X509Certificate; + +// Соподписание (по ветке форка sozertsatel/SignService): поиск подписей документа по +// содержимому, явный режим, подтверждения, резервные копии, имя результата +// «Объединить .sig» и проверка ГОСТ с параметрами в signatureAlgorithm (КриптоПро/CryptoAPI). +internal static class CoSignTests +{ + private static readonly VerificationOptions Crypto = new() + { CheckCertificateTrust = false, CheckRevocation = false, UseSystemTrustStore = false }; + + public static async Task RunAsync(string tempRoot, DocumentSigner signer) + { + var root = Path.Combine(tempRoot, "cosign"); + Directory.CreateDirectory(root); + using var certA = MakeCert("CN=Подписант А"); + using var certB = MakeCert("CN=Подписант Б"); + using var certC = MakeCert("CN=Подписант В"); + var data = System.Text.Encoding.UTF8.GetBytes("актуальный документ"); + var other = System.Text.Encoding.UTF8.GetBytes("другая версия документа"); + + await CheckDiscoveryAndAppend(root, signer, certA, certB, data); + await CheckCreateNewConfirmation(root, signer, certA, certB, data); + await CheckExclusionConfirmation(root, signer, certA, certB, certC, data, other); + await CheckUnmatchedSignatureListed(root, signer, certA, data, other); + await CheckDroppedFromOtherFolder(root, signer, certA, certB, data); + await CheckDiscoveryInBackground(root, signer, certA, data); + await CheckCanonicalWithoutSignedAttributes(root, signer, certA, certB, data); + await CheckStampedCopyLeavesOriginalSignatures(root, signer, certA, certB, certC); + CheckMergeNamesOutput(root, signer, certA, certB, data); + CheckGostParameters(); + await CheckSamplesIfPresent(root); + Console.WriteLine("cosign: discovery, confirmation, backup, merge name, GOST parameters: OK"); + } + + private static async Task CheckDiscoveryAndAppend(string root, DocumentSigner signer, + X509Certificate2 certA, X509Certificate2 certB, byte[] data) + { + var dir = Path.Combine(root, "append"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "договор.pdf"); + var odd = Path.Combine(dir, "договор-.pdf .sig"); + await File.WriteAllBytesAsync(document, data); + var original = signer.Sign(data, certA); + await File.WriteAllBytesAsync(odd, original); + + var vm = ViewModel(dir); + await vm.AddFilesAsync(odd, document); // подпись раньше документа — всё равно находится по содержимому + var item = vm.Files.Single(); + Assert(item.StatusDisplay.Contains("будет добавлена подпись №2") + && item.StatusDisplay.Contains("договор-.pdf .sig") + && item.StatusDisplay.Contains("1 подписант") + && !item.StatusDisplay.Contains("приложенные"), + "plan must name the odd signature and the next signer number: " + item.StatusDisplay); + Assert(item.SelectedCoSignOption is { CreateNew: false }, "default mode adds to the discovered signature"); + Assert(SignatureDiscovery.SamePath(item.PlannedOutputPath, odd), "output is the discovered file, not a new имя.sig"); + + vm.SelectedCertificate = new CertificateItem(certB); + await vm.SignAllCommand.ExecuteAsync(null); + Assert(item.Status == SignStatus.Signed, "append sign failed: " + item.Message); + Assert(item.StatusDisplay.StartsWith("Подписан: 2 (1 + ваша) → договор-.pdf .sig", StringComparison.Ordinal), + "signed row must show previous count and target: " + item.StatusDisplay); + Assert(!File.Exists(document + ".sig"), "canonical имя.sig must not appear when appending to the odd file"); + Assert(CmsMerger.CountSigners(await File.ReadAllBytesAsync(odd)) == 2, "odd file must now have 2 signers"); + Assert(Directory.GetFiles(dir, "*.bak").Length == 0, + "appending keeps every previous signer, a backup copy would only clutter the folder"); + var appended = await File.ReadAllBytesAsync(odd); + Assert(SignatureValues(appended).Any(value => value.SequenceEqual(SignatureValues(original)[0])), + "the previous signer must be kept byte for byte"); + Assert(signer.VerifyDetached(data, appended), "appended signature must verify"); + + // После подписания вариант обновлён: соподписание следующим сертификатом дописывает в тот же файл. + vm.SelectedCertificate = new CertificateItem(certA); + Assert(item.Status == SignStatus.Pending && SignatureDiscovery.SamePath(item.PlannedOutputPath, odd) + && item.StatusDisplay.Contains("2 подписанта"), + "after a certificate change the row must offer the updated file: " + item.StatusDisplay); + Console.WriteLine("cosign: odd name discovered by content, append shows «2 (1 + ваша)», no backup clutter: OK"); + } + + private static async Task CheckCreateNewConfirmation(string root, DocumentSigner signer, + X509Certificate2 certA, X509Certificate2 certB, byte[] data) + { + var dir = Path.Combine(root, "create_new"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "заявка.pdf"); + var odd = Path.Combine(dir, "заявка-.pdf .sig"); + var canonical = document + ".sig"; + await File.WriteAllBytesAsync(document, data); + var originalOdd = signer.Sign(data, certA); + var originalCanonical = signer.Sign(data, certA); + await File.WriteAllBytesAsync(odd, originalOdd); + await File.WriteAllBytesAsync(canonical, originalCanonical); + + var vm = ViewModel(dir); + await vm.AddFilesAsync(document); + vm.MergeWithExisting = false; + var item = vm.Files.Single(); + Assert(item.StatusDisplay.Contains("будет создан новый файл подписи") + && item.StatusDisplay.Contains("будет заменён"), + "create-new row must warn that the canonical file will be replaced: " + item.StatusDisplay); + var risks = vm.DescribeSignRisks(vm.Files.ToList()); + Assert(risks is not null && risks.Contains("заявка-.pdf .sig") && risks.Contains("перезаписан без объединения"), + "confirmation must mention the matching signature and the overwrite: " + risks); + + vm.SelectedCertificate = new CertificateItem(certB); + vm.RequestConfirmAsync = (_, _) => Task.FromResult(false); + await vm.SignAllCommand.ExecuteAsync(null); + Assert(item.Status == SignStatus.Pending, "declined confirmation must leave the file unsigned"); + Assert((await File.ReadAllBytesAsync(odd)).SequenceEqual(originalOdd), "declined sign must not touch the odd signature"); + Assert((await File.ReadAllBytesAsync(canonical)).SequenceEqual(originalCanonical), "declined sign must not overwrite canonical .sig"); + + vm.RequestConfirmAsync = (_, _) => Task.FromResult(true); + await vm.SignAllCommand.ExecuteAsync(null); + Assert(item.Status == SignStatus.Signed && item.StatusDisplay.StartsWith("Подписан: 1, новый файл", StringComparison.Ordinal), + "confirmed create-new must say so: " + item.StatusDisplay); + Assert((await File.ReadAllBytesAsync(odd)).SequenceEqual(originalOdd), "create-new must leave the other signature untouched"); + Assert(CmsMerger.CountSigners(await File.ReadAllBytesAsync(canonical)) == 1, "overwritten canonical file has only the new signer"); + var backup = Directory.GetFiles(dir, "заявка.pdf.sig.*.bak").Single(); + Assert((await File.ReadAllBytesAsync(backup)).SequenceEqual(originalCanonical), "overwrite keeps a backup of the previous .sig"); + Console.WriteLine("cosign: create-new asks before ignoring a match and before overwrite; backup kept: OK"); + } + + private static async Task CheckExclusionConfirmation(string root, DocumentSigner signer, + X509Certificate2 certA, X509Certificate2 certB, X509Certificate2 certC, byte[] data, byte[] other) + { + var dir = Path.Combine(root, "exclude"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "смета.pdf"); + var odd = Path.Combine(dir, "смета-.pdf .sig"); + await File.WriteAllBytesAsync(document, data); + var mixed = CmsMerger.Merge(new[] { signer.Sign(data, certA), signer.Sign(other, certB) }); + await File.WriteAllBytesAsync(odd, mixed); + + var vm = ViewModel(dir); + await vm.AddFilesAsync(document); + var item = vm.Files.Single(); + Assert(item.HasWarning && item.WarningDisplay!.Contains("Перед исключением"), + "row must warn before signers would be dropped: " + item.WarningDisplay); + var risks = vm.DescribeSignRisks(vm.Files.ToList()); + Assert(risks is not null && risks.Contains("исключены подписанты") && risks.Contains("Подписант Б"), + "confirmation must name the signer that would be dropped: " + risks); + + vm.SelectedCertificate = new CertificateItem(certC); + vm.RequestConfirmAsync = (_, _) => Task.FromResult(false); + await vm.SignAllCommand.ExecuteAsync(null); + Assert((await File.ReadAllBytesAsync(odd)).SequenceEqual(mixed), "declined exclusion must keep the original signature"); + + vm.RequestConfirmAsync = (_, _) => Task.FromResult(true); + await vm.SignAllCommand.ExecuteAsync(null); + Assert(item.Status == SignStatus.Signed, "sign after confirmed exclusion failed: " + item.Message); + Assert(item.HasWarning && item.WarningDisplay!.StartsWith("Внимание: исключены подписанты", StringComparison.Ordinal) + && item.WarningDisplay.Contains("Подписант Б"), + "excluded signer must stay visible after signing: " + item.WarningDisplay); + var result = SignatureVerifier.Verify(await File.ReadAllBytesAsync(odd), data, Crypto); + Assert(result.Signers.Count == 2 && result.Signers.All(s => s.CryptographicallyValid), + "kept signers must verify; dropped signer must be gone"); + var backup = Directory.GetFiles(dir, "*.bak").Single(); + Assert((await File.ReadAllBytesAsync(backup)).SequenceEqual(mixed), "exclusion keeps a backup with the dropped signer"); + Console.WriteLine("cosign: exclusion needs confirmation, stays a warning on the row, backup kept: OK"); + } + + private static async Task CheckUnmatchedSignatureListed(string root, DocumentSigner signer, + X509Certificate2 certA, byte[] data, byte[] other) + { + var dir = Path.Combine(root, "unmatched"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "письмо.txt"); + await File.WriteAllBytesAsync(document, data); + var foreign = Path.Combine(dir, "чужая подпись.sig"); + await File.WriteAllBytesAsync(foreign, signer.Sign(other, certA)); + var garbage = Path.Combine(dir, "битая.p7s"); + await File.WriteAllBytesAsync(garbage, new byte[] { 1, 2, 3, 4 }); + + var vm = ViewModel(dir); + await vm.AddFilesAsync(document, foreign, garbage); + Assert(vm.Files.Count == 1, "signature files must not become documents in the queue"); + Assert(vm.StatusText.Contains("«чужая подпись.sig»") && vm.StatusText.Contains("«битая.p7s»") + && vm.StatusText.Contains("не сопоставлены", StringComparison.OrdinalIgnoreCase), + "unmatched signatures must be listed, not silently skipped: " + vm.StatusText); + Assert(!vm.Files[0].StatusDisplay.Contains("чужая"), "foreign signature must not be attached: " + vm.Files[0].StatusDisplay); + + await vm.AddFilesAsync(Path.Combine(dir, "письмо-.txt .sig")); + await File.WriteAllBytesAsync(Path.Combine(dir, "письмо-.txt .sig"), signer.Sign(data, certA)); + await vm.AddFilesAsync(Path.Combine(dir, "письмо-.txt .sig")); + Assert(vm.Files[0].StatusDisplay.Contains("будет добавлена подпись №2") + && vm.Files[0].StatusDisplay.Contains("письмо-.txt .sig"), + "a signature dropped later must attach by hash: " + vm.Files[0].StatusDisplay); + Console.WriteLine("cosign: unmatched .sig/.p7s are listed; a later drop still matches by hash: OK"); + } + + private static void CheckMergeNamesOutput(string root, DocumentSigner signer, + X509Certificate2 certA, X509Certificate2 certB, byte[] data) + { + var dir = Path.Combine(root, "merge_name"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "акт.pdf"); + File.WriteAllBytes(document, data); + var odd = Path.Combine(dir, "акт-.pdf .sig"); + var second = Path.Combine(dir, "вторая.p7s"); + File.WriteAllBytes(odd, signer.Sign(data, certA)); + File.WriteAllBytes(second, signer.Sign(data, certB)); + var canonical = document + ".sig"; + File.WriteAllBytes(canonical, new byte[] { 9, 9, 9 }); + + var merged = CmsExtractor.MergeSignatureFiles(new[] { odd, second }); + Assert(Path.GetFileName(merged.OutputPath) == "акт.pdf (1).sig", + "taken canonical name must get a unique suffix, not the odd input name: " + merged.OutputPath); + Assert(File.ReadAllBytes(canonical).SequenceEqual(new byte[] { 9, 9, 9 }), "existing акт.pdf.sig must stay untouched"); + Assert(merged.DocumentNote.Contains("акт.pdf") && merged.DocumentNote.Contains("не перезаписан"), + "note must say the document was found and the existing file was kept: " + merged.DocumentNote); + Assert(CmsMerger.CountSigners(File.ReadAllBytes(merged.OutputPath)) == 2, "merged file keeps both signers"); + + File.Delete(canonical); + var cleanDir = Path.Combine(dir, "clean"); + Directory.CreateDirectory(cleanDir); + var cleanDoc = Path.Combine(cleanDir, "акт.pdf"); + File.WriteAllBytes(cleanDoc, data); + var cleanOdd = Path.Combine(cleanDir, "акт-.pdf .sig"); + File.WriteAllBytes(cleanOdd, signer.Sign(data, certA)); + File.WriteAllBytes(Path.Combine(cleanDir, "вторая.p7s"), signer.Sign(data, certB)); + var named = CmsExtractor.MergeSignatureFiles(new[] { cleanOdd, Path.Combine(cleanDir, "вторая.p7s") }); + Assert(Path.GetFileName(named.OutputPath) == "акт.pdf.sig", + "when the document is known and имя.sig is free, that is the output: " + named.OutputPath); + Console.WriteLine("cosign: merge output is «документ.sig» and does not clobber an existing file: OK"); + } + + // Подпись, перетащенная из другой папки, — приложенная: объединяется с вашей, + // результат пишется рядом с документом, сам перетащенный файл не меняется. + private static async Task CheckDroppedFromOtherFolder(string root, DocumentSigner signer, + X509Certificate2 certA, X509Certificate2 certB, byte[] data) + { + var dir = Path.Combine(root, "dropped"); + var mail = Path.Combine(root, "dropped_mail"); + Directory.CreateDirectory(dir); + Directory.CreateDirectory(mail); + var document = Path.Combine(dir, "акт сверки.pdf"); + await File.WriteAllBytesAsync(document, data); + var fromMail = Path.Combine(mail, "подпись контрагента.p7s"); + var foreignBytes = signer.Sign(data, certA); + await File.WriteAllBytesAsync(fromMail, foreignBytes); + + var vm = ViewModel(dir); + await vm.AddFilesAsync(document, fromMail); + var item = vm.Files.Single(); + Assert(item.ExtraCount == 1 && item.StatusDisplay.Contains("приложенные подписи (1)"), + "a matching signature from another folder must be attached: " + item.StatusDisplay); + vm.SelectedCertificate = new CertificateItem(certB); + await vm.SignAllCommand.ExecuteAsync(null); + Assert(item.Status == SignStatus.Signed && CmsMerger.CountSigners(await File.ReadAllBytesAsync(document + ".sig")) == 2, + "result must be «документ.sig» with both signers: " + item.StatusDisplay); + Assert((await File.ReadAllBytesAsync(fromMail)).SequenceEqual(foreignBytes), "the dropped file must stay untouched"); + Console.WriteLine("cosign: signature from another folder is attached, result next to the document, source untouched: OK"); + } + + // Поиск подписей идёт в фоне: добавление файла не держит поток интерфейса, подписание ждёт поиска. + private static async Task CheckDiscoveryInBackground(string root, DocumentSigner signer, X509Certificate2 certA, byte[] data) + { + var dir = Path.Combine(root, "background"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "отчёт.pdf"); + await File.WriteAllBytesAsync(document, data); + await File.WriteAllBytesAsync(Path.Combine(dir, "отчёт-.pdf .sig"), signer.Sign(data, certA)); + var vm = ViewModel(dir); + vm.SelectedCertificate = new CertificateItem(certA); + var adding = vm.AddFilesAsync(document); + var item = vm.Files.Single(); + if (!adding.IsCompleted) + { + Assert(item.IsDiscovering && item.StatusDisplay.Contains("поиск подписей") && !item.CanChooseCoSign, + "row must show the search while it runs: " + item.StatusDisplay); + Assert(!vm.SignAllCommand.CanExecute(null), "signing must wait until the search finishes"); + } + + await adding; + Assert(!item.IsDiscovering && item.CanChooseCoSign && vm.SignAllCommand.CanExecute(null) + && item.StatusDisplay.Contains("будет добавлена подпись №2"), "search result must be applied: " + item.StatusDisplay); + Console.WriteLine("cosign: signature search runs in the background, signing waits for it: OK"); + } + + // «документ.sig» с подписью без подписанных атрибутов (голый PKCS#7) по messageDigest не + // сопоставить — он всё равно предлагается, проверяется по содержимому и сохраняется. + private static async Task CheckCanonicalWithoutSignedAttributes(string root, DocumentSigner signer, + X509Certificate2 certA, X509Certificate2 certB, byte[] data) + { + var dir = Path.Combine(root, "direct"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "приказ.pdf"); + await File.WriteAllBytesAsync(document, data); + var key = DotNetUtilities.GetRsaKeyPair(certA.GetRSAPrivateKey()!); + var bcCert = new X509CertificateParser().ReadCertificate(certA.RawData); + var generator = new CmsSignedDataGenerator(); + generator.AddSignerInfoGenerator(new SignerInfoGeneratorBuilder().SetDirectSignature(true) + .Build(new Asn1SignatureFactory("SHA256WITHRSA", key.Private), bcCert)); + generator.AddCertificates(Org.BouncyCastle.Utilities.Collections.CollectionUtilities.CreateStore(new[] { bcCert })); + var direct = generator.Generate(new CmsProcessableByteArray(data), false).GetEncoded(); + await File.WriteAllBytesAsync(document + ".sig", direct); + + var vm = ViewModel(dir); + await vm.AddFilesAsync(document); + var item = vm.Files.Single(); + Assert(item.SelectedCoSignOption is { CreateNew: false } option && SignatureDiscovery.SamePath(option.SignaturePath!, document + ".sig") + && !item.HasWarning, "«документ.sig» with a direct signature must be offered without exclusion warnings: " + item.StatusDisplay); + vm.SelectedCertificate = new CertificateItem(certB); + await vm.SignAllCommand.ExecuteAsync(null); + var check = SignatureVerifier.Verify(await File.ReadAllBytesAsync(document + ".sig"), data, Crypto); + Assert(item.Status == SignStatus.Signed && check.Signers.Count == 2 && check.Signers.All(s => s.CryptographicallyValid), + "the direct signer must be kept next to the new one: " + item.StatusDisplay); + Console.WriteLine("cosign: «документ.sig» with a plain PKCS#7 signer is offered and kept: OK"); + } + + // Режим «подписывать копию со штампом»: подпись ставится на другой документ (копию), + // поэтому найденная групповая подпись оригинала не трогается и не «исключается». + private static async Task CheckStampedCopyLeavesOriginalSignatures(string root, DocumentSigner signer, + X509Certificate2 certA, X509Certificate2 certB, X509Certificate2 certC) + { + var dir = Path.Combine(root, "stamped_copy"); + Directory.CreateDirectory(dir); + var document = Path.Combine(dir, "договор.pdf"); + _ = PdfStamper.IsPdf(document); // инициализация шрифтов PDFsharp + using (var pdf = new PdfSharp.Pdf.PdfDocument()) + { + pdf.AddPage(); + pdf.Save(document); + } + + var data = await File.ReadAllBytesAsync(document); + var group = Path.Combine(dir, "договор-.pdf .sig"); + var groupBytes = CmsMerger.Merge(new[] { signer.Sign(data, certA), signer.Sign(data, certB) }); + await File.WriteAllBytesAsync(group, groupBytes); + + var vm = ViewModel(dir); + vm.UseStamp = true; + vm.StampSignCopy = true; + vm.RequestStampOptionsAsync = _ => Task.FromResult( + new SignService.Views.StampOptionsDialog.Result(vm.BuildInitialStampOptions(), SignCopy: true)); + await vm.AddFilesAsync(document); + var item = vm.Files.Single(); + Assert(item.SignsStampedCopy && item.StatusDisplay.Contains("копия со штампом") && !item.CanChooseCoSign, + "sign-copy mode must not offer the original's signatures: " + item.StatusDisplay); + Assert(vm.DescribeSignRisks(vm.Files.ToList()) is null, "sign-copy mode must not ask about the original's signatures"); + vm.SelectedCertificate = new CertificateItem(certC); + await vm.SignAllCommand.ExecuteAsync(null); + Assert(item.Status == SignStatus.Signed && string.IsNullOrEmpty(item.Warning), + "stamped copy must be signed without exclusions: " + item.StatusDisplay + " " + item.Warning); + Assert((await File.ReadAllBytesAsync(group)).SequenceEqual(groupBytes), "the original's group signature must stay untouched"); + Assert(item.SignaturePath is { } copySignature && !SignatureDiscovery.SamePath(copySignature, group) + && CmsMerger.CountSigners(await File.ReadAllBytesAsync(copySignature)) == 1, + "the stamped copy gets its own signature file: " + item.SignaturePath); + Console.WriteLine("cosign: «sign the stamped copy» leaves the original's group signature untouched: OK"); + } + + // КриптоПро и CryptoAPI записывают в signatureAlgorithm OID ключа ГОСТ с его параметрами. + // Такие подписи проверяются полностью, без ослабления проверок. + private static void CheckGostParameters() + { + var variants = new[] + { + (Name: "2012-256", Curve: RosstandartObjectIdentifiers.id_tc26_gost_3410_12_256_paramSetA, + Digest: RosstandartObjectIdentifiers.id_tc26_gost_3411_12_256, + Algorithm: "GOST3411-2012-256WITHECGOST3410-2012-256", KeyOid: "1.2.643.7.1.1.1.1", + Parameters: new[] { "1.2.643.7.1.2.1.1.1", "1.2.643.7.1.1.2.2" }), + (Name: "2012-512", Curve: RosstandartObjectIdentifiers.id_tc26_gost_3410_12_512_paramSetA, + Digest: RosstandartObjectIdentifiers.id_tc26_gost_3411_12_512, + Algorithm: "GOST3411-2012-512WITHECGOST3410-2012-512", KeyOid: "1.2.643.7.1.1.1.2", + Parameters: new[] { "1.2.643.7.1.2.1.2.1", "1.2.643.7.1.1.2.3" }), + (Name: "2001", Curve: CryptoProObjectIdentifiers.GostR3410x2001CryptoProA, + Digest: CryptoProObjectIdentifiers.GostR3411x94CryptoProParamSet, + Algorithm: "GOST3411WITHECGOST3410", KeyOid: "1.2.643.2.2.19", + Parameters: new[] { "1.2.643.2.2.35.1", "1.2.643.2.2.30.1" }), + }; + var data = System.Text.Encoding.UTF8.GetBytes("ГОСТ с параметрами signatureAlgorithm"); + var other = System.Text.Encoding.UTF8.GetBytes("другой документ"); + foreach (var variant in variants) + { + var curve = ECGost3410NamedCurves.GetByOid(variant.Curve); + var generator = new ECKeyPairGenerator("ECGOST3410"); + generator.Init(new ECKeyGenerationParameters(new ECGost3410Parameters( + new ECNamedDomainParameters(variant.Curve, curve), variant.Curve, variant.Digest, null), new SecureRandom())); + var pair = generator.GenerateKeyPair(); + var cert = GostCert("GOST " + variant.Name, pair, pair, variant.Algorithm); + var plain = GostSign(data, cert, pair, variant.Algorithm, withMessageDigest: true); + Assert(SignatureVerifier.Verify(plain, data, Crypto).CryptographicallyValid, variant.Name + ": GOST without parameters still verifies"); + + foreach (var (label, signed) in new[] + { + ("signature OID + parameters", WithSignatureAlgorithm(plain, null, variant.Parameters)), + ("key OID + parameters (CryptoAPI)", WithSignatureAlgorithm(plain, variant.KeyOid, variant.Parameters)), + ("key OID without parameters", WithSignatureAlgorithm(plain, variant.KeyOid, null)), + }) + { + var check = SignatureVerifier.Verify(signed, data, Crypto).Signers.Single(); + Assert(check.CryptographicallyValid, $"{variant.Name} {label} must verify: {check.Signature.Message}"); + Assert(SignatureVerifier.Verify(signed, other, Crypto).Signers.Single().Document.State == VerificationState.Invalid, + $"{variant.Name} {label}: another document must be rejected"); + var damaged = signed.ToArray(); + var value = SignatureValues(signed)[0]; + damaged[damaged.AsSpan().IndexOf(value) + value.Length - 1] ^= 1; + Assert(SignatureVerifier.Verify(damaged, data, Crypto).Signers.Single().Signature.State == VerificationState.Invalid, + $"{variant.Name} {label}: damaged value must be invalid"); + } + + // Без messageDigest подпись не связана с документом — параметры не дают обойти проверку. + var unbound = WithSignatureAlgorithm(GostSign(data, cert, pair, variant.Algorithm, withMessageDigest: false), + variant.KeyOid, variant.Parameters); + var unboundCheck = SignatureVerifier.Verify(unbound, other, Crypto).Signers.Single(); + Assert(!unboundCheck.CryptographicallyValid && unboundCheck.Document.State == VerificationState.Invalid, + $"{variant.Name}: signed attributes without messageDigest must not verify any document: {unboundCheck.Document.Message}"); + + var second = generator.GenerateKeyPair(); + var secondCert = GostCert("GOST second " + variant.Name, second, second, variant.Algorithm); + var cryptoApi = WithSignatureAlgorithm(plain, variant.KeyOid, variant.Parameters); + var merged = CmsMerger.MergeForDocument(new[] { cryptoApi, GostSign(data, secondCert, second, variant.Algorithm, true) }, data); + Assert(merged.SignerCount == 2 && merged.ExcludedSigners.Count == 0 && merged.UnverifiedSigners.Count == 0, + $"{variant.Name}: CryptoAPI-style signer must be verified, not excluded or unverified"); + Assert(new CmsSignedData(merged.Signature).GetSignerInfos().GetSigners() + .Any(signer => signer.SignatureAlgorithm.Algorithm.Id == variant.KeyOid && signer.SignatureAlgorithm.Parameters is not null), + $"{variant.Name}: merge must keep the original signatureAlgorithm with parameters"); + } + + Console.WriteLine("cosign: GOST 2001/2012 with signatureAlgorithm parameters verify fully; unbound and damaged rejected: OK"); + } + + private static async Task CheckSamplesIfPresent(string root) + { + // Архив с реальными подписями (групповая на 8 подписантов + одиночная) — по желанию. + var zip = Environment.GetEnvironmentVariable("SIGNSERVICE_SAMPLES"); + if (string.IsNullOrWhiteSpace(zip) || !File.Exists(zip)) + { + Console.WriteLine("cosign: real samples not set (SIGNSERVICE_SAMPLES), synthetic GOST cases covered the verifier"); + return; + } + + var dir = Path.Combine(root, "samples"); + Directory.CreateDirectory(dir); + ZipFile.ExtractToDirectory(zip, dir, overwriteFiles: true); + var pdf = Directory.GetFiles(dir, "*.pdf").Single(path => !path.EndsWith(".sig", StringComparison.OrdinalIgnoreCase)); + var signatures = Directory.GetFiles(dir, "*.sig"); + var eight = signatures.Single(path => CmsMerger.CountSigners(File.ReadAllBytes(path)) == 8); + var one = signatures.Single(path => CmsMerger.CountSigners(File.ReadAllBytes(path)) == 1); + var document = await File.ReadAllBytesAsync(pdf); + var oldSignature = await File.ReadAllBytesAsync(eight); + var newSignature = await File.ReadAllBytesAsync(one); + var oldValues = SignatureValues(oldSignature); + + var oldCheck = SignatureVerifier.Verify(oldSignature, document, Crypto); + Assert(oldCheck.Signers.Count == 8 && oldCheck.Signers.All(s => s.CryptographicallyValid), + "sample 8 signers: " + string.Join(" | ", oldCheck.Signers.Select(s => s.Signature.State + " " + s.Document.State + " " + s.CertificateBinding.State))); + + var merged = CmsMerger.MergeForDocument(new[] { oldSignature, newSignature }, document, attach: false); + var mergedValues = SignatureValues(merged.Signature); + var preserved = oldValues.Count(value => mergedValues.Any(item => item.SequenceEqual(value))); + var check = SignatureVerifier.Verify(merged.Signature, document, Crypto); + Assert(merged.SignerCount == 9 && check.Signers.Count == 9 && check.Signers.All(s => s.CryptographicallyValid), + "sample merge 9 signers: " + string.Join(" | ", check.Signers.Select(s => + $"{s.Number}:{s.Signature.State}/{s.Document.State}/{s.CertificateBinding.State} {s.Signature.Message}"))); + Assert(preserved == 8, $"original signature values preserved {preserved}/8"); + + // В папке образца лежит и ошибочный одноподписный файл — он тоже совпадает по хешу. + // Сценарий пользователя: только групповая подпись с нестандартным именем. + var clean = Path.Combine(dir, "only_group"); + Directory.CreateDirectory(clean); + var cleanPdf = Path.Combine(clean, Path.GetFileName(pdf)); + var cleanSig = Path.Combine(clean, Path.GetFileName(eight)); + await File.WriteAllBytesAsync(cleanPdf, document); + await File.WriteAllBytesAsync(cleanSig, oldSignature); + var vm = ViewModel(clean); + await vm.AddFilesAsync(cleanPdf); + var plan = vm.Files.Single().StatusDisplay; + Assert(plan.Contains("будет добавлена подпись №9") && plan.Contains("8 подписантов") + && plan.Contains(Path.GetFileName(eight)), + "queue must offer to append signer 9 to the 8-signer file: " + plan); + Console.WriteLine($"cosign samples: signers=9 allValid=true originalSignatureValuesPreserved={preserved}/8 plan={plan}"); + } + + private static MainWindowViewModel ViewModel(string dir) + { + var settings = new AppSettings(Path.Combine(dir, "settings")) { CheckUpdatesOnStart = false }; + return new MainWindowViewModel(new CertificateProvider(), new DocumentSigner(), settings, + new CertificateVault(Path.Combine(dir, "vault"))); + } + + private static X509Certificate2 MakeCert(string subject) + { + using var key = RSA.Create(2048); + return new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) + .CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(1)); + } + + private static BcCertificate GostCert(string name, AsymmetricCipherKeyPair key, AsymmetricCipherKeyPair issuerKey, string algorithm) + { + var generator = new X509V3CertificateGenerator(); + generator.SetSerialNumber(BigInteger.ValueOf(name.GetHashCode() & 0x7fffffff)); + generator.SetSubjectDN(new X509Name("CN=" + name)); + generator.SetIssuerDN(new X509Name("CN=" + name)); + generator.SetNotBefore(DateTime.UtcNow.AddDays(-1)); + generator.SetNotAfter(DateTime.UtcNow.AddYears(1)); + generator.SetPublicKey(key.Public); + generator.AddExtension(X509Extensions.BasicConstraints, true, new BasicConstraints(false)); + generator.AddExtension(X509Extensions.KeyUsage, true, new KeyUsage(KeyUsage.DigitalSignature)); + return generator.Generate(new Asn1SignatureFactory(algorithm, issuerKey.Private)); + } + + private static byte[] GostSign(byte[] data, BcCertificate cert, AsymmetricCipherKeyPair key, string algorithm, + bool withMessageDigest) + { + var builder = new SignerInfoGeneratorBuilder(); + if (!withMessageDigest) + builder.WithSignedAttributeGenerator(new FixedAttributes(new Org.BouncyCastle.Asn1.Cms.AttributeTable( + new Asn1EncodableVector(new Org.BouncyCastle.Asn1.Cms.Attribute(Org.BouncyCastle.Asn1.Cms.CmsAttributes.ContentType, + new DerSet(Org.BouncyCastle.Asn1.Cms.CmsObjectIdentifiers.Data)))))); + var generator = new CmsSignedDataGenerator(); + generator.AddSignerInfoGenerator(builder.Build(new Asn1SignatureFactory(algorithm, key.Private), cert)); + generator.AddCertificates(Org.BouncyCastle.Utilities.Collections.CollectionUtilities.CreateStore(new[] { cert })); + return generator.Generate(new CmsProcessableByteArray(data), encapsulate: false).GetEncoded(); + } + + /// signatureAlgorithm первого подписанта: другой OID (null — прежний) и параметры (null — без них). + private static byte[] WithSignatureAlgorithm(byte[] encoded, string? oid, string[]? parameters) + { + var content = Org.BouncyCastle.Asn1.Cms.ContentInfo.GetInstance(Asn1Object.FromByteArray(encoded)); + var signed = Org.BouncyCastle.Asn1.Cms.SignedData.GetInstance(content.Content); + var signerInfo = Org.BouncyCastle.Asn1.Cms.SignerInfo.GetInstance(signed.SignerInfos[0]); + var algorithm = oid is null ? signerInfo.SignatureAlgorithm.Algorithm : new DerObjectIdentifier(oid); + var identifier = parameters is null + ? new AlgorithmIdentifier(algorithm) + : new AlgorithmIdentifier(algorithm, new DerSequence(parameters.Select(p => (Asn1Encodable)new DerObjectIdentifier(p)).ToArray())); + var changed = new Org.BouncyCastle.Asn1.Cms.SignerInfo( + signerInfo.SignerID, signerInfo.DigestAlgorithm, signerInfo.SignedAttrs, + identifier, signerInfo.Signature, signerInfo.UnsignedAttrs); + return new Org.BouncyCastle.Asn1.Cms.ContentInfo(content.ContentType, new Org.BouncyCastle.Asn1.Cms.SignedData( + signed.DigestAlgorithms, signed.EncapContentInfo, signed.Certificates, signed.CRLs, + new DerSet(changed))).GetEncoded(); + } + + private sealed class FixedAttributes(Org.BouncyCastle.Asn1.Cms.AttributeTable table) : CmsAttributeTableGenerator + { + public Org.BouncyCastle.Asn1.Cms.AttributeTable GetAttributes(IDictionary parameters) => table; + } + + private static List SignatureValues(byte[] signature) => + new CmsSignedData(signature).GetSignerInfos().GetSigners().Select(signer => signer.GetSignature()).ToList(); + + private static void Assert(bool condition, string message) + { + if (!condition) + throw new Exception("cosign: " + message); + } +} diff --git a/tests/SignService.Tests/Program.cs b/tests/SignService.Tests/Program.cs index 87c2ae1..38542be 100644 --- a/tests/SignService.Tests/Program.cs +++ b/tests/SignService.Tests/Program.cs @@ -195,7 +195,7 @@ int Convert(ReadOnlySpan s, MemoryStream o) try { - mergerType.GetMethod("MergeForDocument")!.Invoke(null, new object?[] { new[] { sigStale }, payload, null }); + mergerType.GetMethod("MergeForDocument")!.Invoke(null, new object?[] { new[] { sigStale }, payload, null, true }); throw new Exception("expected failure when all signatures mismatch"); } catch (TargetInvocationException e) @@ -462,9 +462,12 @@ int Convert(ReadOnlySpan s, MemoryStream o) if (!mCms2.ContentInfo.Content.AsSpan().SequenceEqual(payload)) throw new Exception("attached merge lost document"); Console.WriteLine("merge w/o signing (attached + detached): attached output, doc preserved, 2 signers verify"); -// в) документа нет — механическое объединение с пометкой -var q1 = Path.Combine(mDir, "неизвестно1.sig"); -var q2 = Path.Combine(mDir, "неизвестно2.sig"); +// в) документа нет — механическое объединение с пометкой. +// Отдельный каталог: иначе поиск по хешу нашёл бы «справка.pdf» из пункта (а). +var noDocDir = Path.Combine(mDir, "без_документа"); +Directory.CreateDirectory(noDocDir); +var q1 = Path.Combine(noDocDir, "неизвестно1.sig"); +var q2 = Path.Combine(noDocDir, "неизвестно2.sig"); await File.WriteAllBytesAsync(q1, sigA); await File.WriteAllBytesAsync(q2, sigC); var mr3 = CmsExtractor.MergeSignatureFiles(new[] { q1, q2 }); @@ -803,6 +806,7 @@ await File.WriteAllTextAsync(expiredXmlPath, await VerificationTests.RunAsync(tempRoot); await ReviewRegressionTests.RunAsync(tempRoot, signer); +await CoSignTests.RunAsync(tempRoot, signer); UiSmokeTests.Run(tempRoot); try { Directory.Delete(tempRoot, true); } catch { } Console.WriteLine("ALL TESTS PASSED"); diff --git a/tests/SignService.Tests/ReviewRegressionTests.cs b/tests/SignService.Tests/ReviewRegressionTests.cs index 90e4d2f..46cbaa0 100644 --- a/tests/SignService.Tests/ReviewRegressionTests.cs +++ b/tests/SignService.Tests/ReviewRegressionTests.cs @@ -96,6 +96,7 @@ public static async Task RunAsync(string tempRoot, DocumentSigner signer) CheckVerifierRobustness(signer, certA, certB, current); await CheckVerificationSettingsAsync(root, signer); await CheckCachesAsync(root, signer, certA, certB, current); + await CheckForkReviewScenariosAsync(root, signer); if (OperatingSystem.IsWindows()) await CheckUpdaterAsync(root); @@ -299,6 +300,96 @@ await File.WriteAllTextAsync(xmlPath, Console.WriteLine("regression: one document hash per algorithm; merge output mode attached/detached/auto: OK"); } + // Сценарии из ревью форка debug23win (PR #2): там объединение сверяло только messageDigest, + // МЧД проверялась без доверия и отзыва, а сертификат мог пропасть вместе с исключённым входом. + private static async Task CheckForkReviewScenariosAsync(string root, DocumentSigner signer) + { + var dir = Path.Combine(root, "fork_review"); + Directory.CreateDirectory(dir); + using var good = MakeCertificate("CN=Добросовестный"); + using var damagedSigner = MakeCertificate("CN=Повреждённая подпись"); + using var own = MakeCertificate("CN=Своя подпись"); + var data = Encoding.UTF8.GetBytes("документ для соподписания"); + var document = Path.Combine(dir, "договор.pdf"); + await File.WriteAllBytesAsync(document, data); + + // 1. Изменённое значение подписи при верном messageDigest исключается во всех путях объединения. + var damaged = signer.Sign(data, damagedSigner); + var value = CmsMerger.GetSignatureValue(damaged); + damaged[damaged.AsSpan().IndexOf(value) + value.Length / 2] ^= 1; + var damagedPath = Path.Combine(dir, "договор.pdf (повреждённая).sig"); + var goodPath = Path.Combine(dir, "договор.pdf (контрагент).sig"); + await File.WriteAllBytesAsync(damagedPath, damaged); + await File.WriteAllBytesAsync(goodPath, signer.Sign(data, good)); + + var mergedFiles = CmsExtractor.MergeSignatureFiles(new[] { damagedPath, goodPath }); + Assert(mergedFiles.ExcludedSigners.SequenceEqual(new[] { "Повреждённая подпись" }) && mergedFiles.SignerCount == 1 + && SignatureVerifier.Verify(await File.ReadAllBytesAsync(mergedFiles.OutputPath), data, Crypto).CryptographicallyValid, + "«Объединить .sig»: damaged signature value must be excluded, result must verify"); + var container = CmsExtractor.BuildContainer(document, new[] { damagedPath, goodPath }); + Assert(container.ExcludedSigners.Count == 1 && container.SignerCount == 1 + && SignatureVerifier.Verify(await File.ReadAllBytesAsync(container.OutputPath), null, Crypto).CryptographicallyValid, + "«Собрать контейнер»: damaged signature value must be excluded"); + var cosigned = await signer.SignFileAsync(document, own, new DocumentSigner.SignOptions + { + MergeWithExisting = false, ExtraSignatures = new[] { damagedPath, goodPath }, + }); + Assert(cosigned.ExcludedSigners.Count == 1 && cosigned.SignerCount == 2 + && SignatureVerifier.Verify(await File.ReadAllBytesAsync(cosigned.SignaturePath), data, Crypto).CryptographicallyValid, + "co-signing: damaged signature value must be excluded, result must verify"); + Console.WriteLine("fork review: damaged signature value with intact messageDigest excluded from merge, container and co-signing: OK"); + + // 2. Сертификат оставшегося подписанта был только в контейнере исключённого — он сохраняется. + var key = Org.BouncyCastle.Security.DotNetUtilities.GetRsaKeyPair(good.GetRSAPrivateKey()!); + var goodBc = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(good.RawData); + var noCertificates = new Org.BouncyCastle.Cms.CmsSignedDataGenerator(); + noCertificates.AddSignerInfoGenerator(new Org.BouncyCastle.Cms.SignerInfoGeneratorBuilder() + .Build(new Org.BouncyCastle.Crypto.Operators.Asn1SignatureFactory("SHA256WITHRSA", key.Private), goodBc)); + var withoutCertificate = noCertificates.Generate(new Org.BouncyCastle.Cms.CmsProcessableByteArray(data), false).GetEncoded(); + Assert(SignatureVerifier.Verify(withoutCertificate, data, Crypto).Signers.Single().Signature.State == VerificationState.Unknown, + "precondition: the signature alone has no certificate"); + var staleCarrier = new Org.BouncyCastle.Cms.CmsSignedData(signer.Sign(Encoding.UTF8.GetBytes("старая версия"), damagedSigner)); + var carrier = Org.BouncyCastle.Cms.CmsSignedData.ReplaceCertificatesAndCrls(staleCarrier, + Org.BouncyCastle.Utilities.Collections.CollectionUtilities.CreateStore( + staleCarrier.GetCertificates().EnumerateMatches(null).Append(goodBc).ToList()), + null, null).GetEncoded(); + var retained = CmsMerger.MergeForDocument(new[] { carrier, withoutCertificate }, data); + var retainedCheck = SignatureVerifier.Verify(retained.Signature, data, Crypto); + Assert(retained.SignerCount == 1 && retained.ExcludedSigners.Count == 1 + && retainedCheck.Signers.Single().CryptographicallyValid + && retainedCheck.Signers.Single().Name.Contains("Добросовестный"), + "certificate of the kept signer must survive the exclusion of its carrier container"); + Console.WriteLine("fork review: certificate from an excluded signer's container is kept for the remaining signer: OK"); + + // 3. МЧД: нет номера и даты выдачи; просроченный и недоверенный самоподписанный сертификат руководителя. + var xmlPath = Path.Combine(dir, "mchd.xml"); + using var representative = MakeCertificate("CN=Представитель, OID.1.2.643.3.131.1.1=123456789012, OID.1.2.643.100.3=12345678901"); + using var headKey = RSA.Create(2048); + async Task Poa(string svDov, X509Certificate2 head) + { + await File.WriteAllTextAsync(xmlPath, $"<Доверенность><СвДов {svDov}/><СвРосОрг ИННЮЛ=\"1234567890\"/>" + + "<СвУпПред><СведФизЛ ИННФЛ=\"123456789012\" СНИЛС=\"12345678901\"><ФИО Фамилия=\"Представитель\"/>"); + await File.WriteAllBytesAsync(xmlPath + ".sig", signer.Sign(await File.ReadAllBytesAsync(xmlPath), head)); + return PowerOfAttorneyService.Validate(PowerOfAttorneyService.Parse(xmlPath, xmlPath + ".sig"), representative); + } + + var validTo = $"СрокДейст=\"{DateTime.Today.AddYears(1):yyyy-MM-dd}\""; + var issued = $"ДатаВыдДовер=\"{DateTime.Today.AddDays(-1):yyyy-MM-dd}\""; + using var validHead = new CertificateRequest("CN=Руководитель", headKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) + .CreateSelfSigned(DateTimeOffset.Now.AddDays(-30), DateTimeOffset.Now.AddYears(1)); + using var expiredHead = new CertificateRequest("CN=Руководитель", headKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) + .CreateSelfSigned(DateTimeOffset.Now.AddDays(-30), DateTimeOffset.Now.AddDays(-5)); + Assert((await Poa(validTo, validHead)).State == PowerOfAttorneyService.CheckState.Error, "POA without number and issue date must be rejected"); + Assert((await Poa($"НомДовер=\"1\" {validTo}", validHead)).State == PowerOfAttorneyService.CheckState.Error, "POA without issue date must be rejected"); + Assert((await Poa($"{issued} {validTo}", validHead)).State == PowerOfAttorneyService.CheckState.Error, "POA without number must be rejected"); + Assert((await Poa($"НомДовер=\"1\" {issued} {validTo}", expiredHead)).State == PowerOfAttorneyService.CheckState.Error, + "POA signed by an expired self-signed head certificate must be rejected"); + var untrusted = await Poa($"НомДовер=\"1\" {issued} {validTo}", validHead); + Assert(untrusted.State == PowerOfAttorneyService.CheckState.Warning && untrusted.Message.Contains("не подтверждены"), + "self-signed head certificate must never give Ok: trust and revocation are not established: " + untrusted.Message); + Console.WriteLine("fork review: POA without number/issue date and with expired or untrusted self-signed head is not Ok: OK"); + } + // Подменяет алгоритм подписи первого подписанта (значение подписи не меняется). private static byte[] WithSignatureAlgorithm(byte[] encoded, string oid) { diff --git a/tests/SignService.Tests/UiSmokeTests.cs b/tests/SignService.Tests/UiSmokeTests.cs index 1407b4a..43e3e7e 100644 --- a/tests/SignService.Tests/UiSmokeTests.cs +++ b/tests/SignService.Tests/UiSmokeTests.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Avalonia; using Avalonia.Controls; using Avalonia.Headless; @@ -61,7 +63,7 @@ private static void RunOnUiThread(string tempRoot) var inspect = items.First(i => i.Header?.ToString()?.StartsWith("Подписанты", StringComparison.Ordinal) == true); inspect.Command!.Execute(inspect.CommandParameter); Dispatcher.UIThread.RunJobs(); - Assert(vm.StatusText.Contains("нет файла подписи"), "context menu command must run for the clicked file: " + vm.StatusText); + Assert(vm.StatusText.Contains("не найден файл подписи"), "context menu command must run for the clicked file: " + vm.StatusText); menu.Close(); var tools = window.GetVisualDescendants().OfType