diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6213879..417bfe0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# История версий SignService
+## v1.4.1 — 20.10.2026
+
+- **Чужая подпись не теряется при одновременном соподписании.** Объединение
+ и контроль изменений используют один снимок файла, снятый до PIN-кода и TSA.
+ Изменение, удаление или появление файла назначения останавливает запись,
+ в том числе в режиме «Создать новую». Перед атомарной заменой выполняется
+ повторная проверка; исчезнувшая выбранная подпись не подменяется другим путём.
+- **Подписанная копия со штампом защищена для всех форматов CMS.** Учитываются
+ `.sig`, `.p7s`, `.p7m` и подписи с другими именами, найденные по содержимому
+ копии, включая обычный PKCS#7 без подписанных атрибутов. Проверка выполняется
+ и непосредственно перед сохранением PDF/Word, исходные файлы сохраняются.
+- Добавлены регрессионные тесты конкурентной записи, удаления и появления
+ подписи, а также защиты подписанных копий PDF и Word.
+
## v1.4.0 — 20.10.2026
- **Штамп в документах Word (.docx, .docm).** С галочкой «Штамп на документ»
diff --git a/README.en.md b/README.en.md
index 6c24f98..77a2d1f 100644
--- a/README.en.md
+++ b/README.en.md
@@ -49,6 +49,8 @@ is published on the [Releases](https://github.com/zaynullinmi/SignService/releas
- 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";
+- if the destination signature changes, disappears or appears during signing,
+ publication stops and preserves the other version, in both signing modes;
- re-signing with the same certificate **replaces** your previous signature
instead of duplicating it;
- every merged signature is **cryptographically verified**, including the
diff --git a/README.md b/README.md
index b701f59..c30099a 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,8 @@
- перед заменой файла подписи без объединения и перед исключением подписантов
программа спрашивает подтверждение и сохраняет резервную копию рядом;
итог виден в строке файла: «Подписан: 9 (8 + ваша) → X.sig»;
+- если файл назначения изменился, исчез или появился во время подписания,
+ запись отменяется и чужая версия сохраняется — в обоих режимах подписания;
- повторная подпись тем же сертификатом **заменяет** свою старую, а не дублирует;
- каждая объединяемая подпись **проверяется криптографически**: хеш документа,
значение подписи и привязка сертификата CAdES — в том числе подписи КриптоПро
diff --git a/src/SignService/Services/AtomicFile.cs b/src/SignService/Services/AtomicFile.cs
index a5e3050..629736f 100644
--- a/src/SignService/Services/AtomicFile.cs
+++ b/src/SignService/Services/AtomicFile.cs
@@ -8,7 +8,7 @@ namespace SignService.Services;
internal static class AtomicFile
{
public static async Task WriteAsync(string path, byte[] bytes, bool overwrite = true,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default, Action? beforeCommit = null)
{
var fullPath = Path.GetFullPath(path);
var temporary = Path.Combine(Path.GetDirectoryName(fullPath)!, ".signservice-" + Guid.NewGuid().ToString("N") + ".tmp");
@@ -22,6 +22,7 @@ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite =
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
+ beforeCommit?.Invoke();
File.Move(temporary, fullPath, overwrite);
}
finally
@@ -30,6 +31,6 @@ public static async Task WriteAsync(string path, byte[] bytes, bool overwrite =
}
}
- public static void Write(string path, byte[] bytes, bool overwrite = false) =>
- WriteAsync(path, bytes, overwrite).GetAwaiter().GetResult();
+ public static void Write(string path, byte[] bytes, bool overwrite = false, Action? beforeCommit = null) =>
+ WriteAsync(path, bytes, overwrite, beforeCommit: beforeCommit).GetAwaiter().GetResult();
}
diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs
index 5cfa01e..9373742 100644
--- a/src/SignService/Services/DocumentSigner.cs
+++ b/src/SignService/Services/DocumentSigner.cs
@@ -42,6 +42,9 @@ public sealed record SignOptions
///
internal Action? BeforePublish { get; init; }
+ // Позволяет воспроизвести внешнее изменение после чтения входов, до подписи.
+ internal Action? AfterSignatureInputsRead { get; init; }
+
/// Пути к .sig других подписантов для объединения.
public IReadOnlyList ExtraSignatures { get; init; } = Array.Empty();
@@ -201,25 +204,14 @@ public async Task SignFileAsync(
: null;
var signaturePath = options.MergeWithExisting
&& !string.IsNullOrWhiteSpace(existingSignaturePath)
- && File.Exists(existingSignaturePath)
? existingSignaturePath
: canonicalSignaturePath;
- // Свою подпись создаём ДО чтения объединяемых файлов, чтобы ошибка
- // подписания не оставила .sig наполовину обработанным.
- var own = Sign(data, certificate, options.Detached);
-
- // Штамп времени TSA (CAdES-T) — неподписанный атрибут на значение подписи.
- if (options.Timestamp)
- {
- if (string.IsNullOrWhiteSpace(options.TsaUrl))
- throw new InvalidOperationException(
- "Для подписи со штампом времени укажите адрес службы штампов времени (TSA).");
-
- var token = await _timestampClient.RequestTokenAsync(
- CmsMerger.GetSignatureValue(own), IsGost(certificate), options.TsaUrl, cancellationToken);
- own = CmsMerger.AddUnsignedAttribute(own, TimestampClient.TimeStampTokenOid, token);
- }
+ // Один снимок назначения — и для объединения, и для контроля изменений.
+ // Читаем до PIN/TSA, в том числе в режиме замены без объединения.
+ var previousBytes = await ReadExistingSignatureAsync(signaturePath, cancellationToken);
+ if (options.MergeWithExisting && !string.IsNullOrWhiteSpace(existingSignaturePath) && previousBytes is null)
+ throw new IOException("Выбранный файл подписи больше не существует. Повторите поиск подписей.");
var inputPaths = new List();
void AddInputPath(string? path)
@@ -235,7 +227,7 @@ void AddInputPath(string? path)
if (options.MergeWithExisting)
{
if (!string.IsNullOrWhiteSpace(existingSignaturePath))
- AddInputPath(existingSignaturePath);
+ inputPaths.Add(existingSignaturePath);
else
AddInputPath(canonicalSignaturePath);
}
@@ -245,20 +237,25 @@ void AddInputPath(string? path)
var inputs = new List();
foreach (var path in inputPaths)
- inputs.Add(await File.ReadAllBytesAsync(path, cancellationToken));
- inputs.Add(own); // своя — последней: при совпадении подписанта она побеждает
+ inputs.Add(SignatureDiscovery.SamePath(path, signaturePath) && previousBytes is not null
+ ? previousBytes : await File.ReadAllBytesAsync(path, cancellationToken));
var appended = inputPaths.Any(path => string.Equals(
Path.GetFullPath(path), Path.GetFullPath(signaturePath), StringComparison.OrdinalIgnoreCase));
- // Снимок того, что вошло в объединение. Перед записью файл читается ещё раз:
- // за время PIN-кода или пакетного подписания его могли обновить.
- byte[]? previousBytes = null;
- var previousSignerCount = 0;
- if (appended)
+ var previousSignerCount = appended && previousBytes is not null ? CmsMerger.CountSigners(previousBytes) : 0;
+ options.AfterSignatureInputsRead?.Invoke();
+
+ var own = Sign(data, certificate, options.Detached);
+ if (options.Timestamp)
{
- previousBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
- previousSignerCount = CmsMerger.CountSigners(previousBytes);
+ if (string.IsNullOrWhiteSpace(options.TsaUrl))
+ throw new InvalidOperationException(
+ "Для подписи со штампом времени укажите адрес службы штампов времени (TSA).");
+ var token = await _timestampClient.RequestTokenAsync(
+ CmsMerger.GetSignatureValue(own), IsGost(certificate), options.TsaUrl, cancellationToken);
+ own = CmsMerger.AddUnsignedAttribute(own, TimestampClient.TimeStampTokenOid, token);
}
+ inputs.Add(own); // своя — последней: при совпадении подписанта она побеждает
int signerCount;
IReadOnlyList excluded;
@@ -291,16 +288,17 @@ void AddInputPath(string? path)
// Галочка добавляет копию и при обычном дописывании. Если файл успели
// изменить после чтения — не пишем ничего, в том числе файлы МЧД.
string? backupPath = null;
- if (File.Exists(signaturePath))
+ var latest = await ReadExistingSignatureAsync(signaturePath, cancellationToken);
+ ThrowIfSignatureChanged(previousBytes, latest);
+ if (latest is not null)
{
- var latest = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
- ThrowIfSignatureChanged(previousBytes, latest);
var riskyOverwrite = !appended || excluded.Count > 0 || LosesEmbeddedDocument(latest, output);
if (riskyOverwrite || options.CreateBackup)
backupPath = SignatureBackup.Create(signaturePath);
}
if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath);
- await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken);
+ await AtomicFile.WriteAsync(signaturePath, output, overwrite: previousBytes is not null, cancellationToken,
+ beforeCommit: () => ThrowIfSignatureChanged(previousBytes, ReadExistingSignature(signaturePath)));
// Режим «копия отдельно»: штампованная копия БЕЗ подписи с подписантами
// итогового .sig — пересоздаётся после каждого подписания. Плашки — только
@@ -341,13 +339,27 @@ void AddInputPath(string? path)
/// Отказ от записи, если файл подписи сменился после того, как его прочитали
/// для объединения. Иначе более новая чужая подпись была бы затёрта нашей копией.
///
- internal static void ThrowIfSignatureChanged(byte[]? snapshot, byte[] latest)
+ internal static void ThrowIfSignatureChanged(byte[]? snapshot, byte[]? latest)
{
- if (snapshot is not null && !snapshot.AsSpan().SequenceEqual(latest))
+ if (snapshot is null ? latest is not null : latest is null || !snapshot.AsSpan().SequenceEqual(latest))
throw new IOException(
"Файл подписи изменился во время подписания. Подпись не сохранена; повторите операцию.");
}
+ private static async Task ReadExistingSignatureAsync(string path, CancellationToken cancellationToken)
+ {
+ try { return await File.ReadAllBytesAsync(path, cancellationToken); }
+ catch (FileNotFoundException) { return null; }
+ catch (DirectoryNotFoundException) { return null; }
+ }
+
+ private static byte[]? ReadExistingSignature(string path)
+ {
+ try { return File.ReadAllBytes(path); }
+ catch (FileNotFoundException) { return null; }
+ catch (DirectoryNotFoundException) { return null; }
+ }
+
///
/// true, если в существующей прикреплённой подписи есть документ, а в новой
/// записи его не будет или он станет другим. Сами значения подписей при этом
diff --git a/src/SignService/Services/DocumentStamper.cs b/src/SignService/Services/DocumentStamper.cs
index a48c41c..806e9c4 100644
--- a/src/SignService/Services/DocumentStamper.cs
+++ b/src/SignService/Services/DocumentStamper.cs
@@ -20,17 +20,35 @@ public static string StampedCopyPath(string filePath) =>
WordStamper.IsWord(filePath) ? WordStamper.StampedCopyPath(filePath) : PdfStamper.StampedCopyPath(filePath);
///
- /// Отказ, если у копии со штампом есть своя подпись («… (со штампом).pdf.sig»):
+ /// Отказ, если у копии со штампом есть своя подпись (в том числе с другим именем):
/// новая копия — другой файл, и та подпись перестала бы ему соответствовать.
///
public static void ThrowIfCopySigned(string filePath)
{
var copy = StampedCopyPath(filePath);
- if (!File.Exists(copy + ".sig"))
+ var signature = new[] { ".sig", ".p7s", ".p7m" }
+ .Select(extension => copy + extension).FirstOrDefault(File.Exists);
+ if (signature is null && File.Exists(copy))
+ {
+ var digests = new DocumentDigests(File.ReadAllBytes(copy));
+ var directory = Path.GetDirectoryName(Path.GetFullPath(copy))!;
+ foreach (var candidate in Directory.EnumerateFiles(directory).Where(SignatureDiscovery.IsSignatureFile))
+ {
+ var report = SignatureVerifier.VerifyWithDigests(File.ReadAllBytes(candidate), digests,
+ VerificationOptions.CryptographyOnly);
+ // Не затираем и при неопределённой криптопроверке, если хеш документа совпал.
+ if (!report.Signers.Any(s => s.Document.State == VerificationState.Valid
+ && s.Signature.State != VerificationState.Invalid))
+ continue;
+ signature = candidate;
+ break;
+ }
+ }
+ if (signature is null)
return;
var name = Path.GetFileName(copy);
throw new InvalidOperationException(
- $"Копия «{name}» уже подписана («{name}.sig»), новая копия со штампом сделала бы эту подпись " +
+ $"Копия «{name}» уже подписана («{Path.GetFileName(signature)}»), новая копия со штампом сделала бы эту подпись " +
$"недействительной. Чтобы добавить подпись к копии, подпишите сам файл «{name}» без штампа; " +
"чтобы сделать копию заново, удалите или переименуйте её файл подписи.");
}
diff --git a/src/SignService/Services/PdfStamper.cs b/src/SignService/Services/PdfStamper.cs
index 81b4d7a..502bf76 100644
--- a/src/SignService/Services/PdfStamper.cs
+++ b/src/SignService/Services/PdfStamper.cs
@@ -121,7 +121,8 @@ public static string CreateStampedCopy(string pdfPath, IReadOnlyList DocumentStamper.ThrowIfCopySigned(pdfPath));
return outputPath;
}
diff --git a/src/SignService/Services/WordStamper.cs b/src/SignService/Services/WordStamper.cs
index 862dbb1..53d7f5f 100644
--- a/src/SignService/Services/WordStamper.cs
+++ b/src/SignService/Services/WordStamper.cs
@@ -95,7 +95,8 @@ public static string CreateStampedCopy(
}
}
- AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true);
+ AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true,
+ beforeCommit: () => DocumentStamper.ThrowIfCopySigned(docPath));
return outputPath;
}
diff --git a/src/SignService/SignService.csproj b/src/SignService/SignService.csproj
index 1553bc3..c85d6a0 100644
--- a/src/SignService/SignService.csproj
+++ b/src/SignService/SignService.csproj
@@ -10,7 +10,7 @@
true
SignService — подписание документов ЭЦП
SignService
- 1.4.0
+ 1.4.1
Зайнуллин Марат Илгамович
Зайнуллин Марат Илгамович
© 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com
diff --git a/tests/SignService.Tests/Program.cs b/tests/SignService.Tests/Program.cs
index 6f22ce3..2e7579a 100644
--- a/tests/SignService.Tests/Program.cs
+++ b/tests/SignService.Tests/Program.cs
@@ -828,6 +828,7 @@ await File.WriteAllTextAsync(expiredXmlPath,
await ReviewRegressionTests.RunAsync(tempRoot, signer);
await CoSignTests.RunAsync(tempRoot, signer);
await WordStampTests.RunAsync(tempRoot, signer);
+await PublicationSafetyTests.RunAsync(tempRoot, signer);
UiSmokeTests.Run(tempRoot);
try { Directory.Delete(tempRoot, true); } catch { }
Console.WriteLine("ALL TESTS PASSED");
diff --git a/tests/SignService.Tests/PublicationSafetyTests.cs b/tests/SignService.Tests/PublicationSafetyTests.cs
new file mode 100644
index 0000000..a6a8537
--- /dev/null
+++ b/tests/SignService.Tests/PublicationSafetyTests.cs
@@ -0,0 +1,194 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.Pkcs;
+using System.Security.Cryptography.X509Certificates;
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Wordprocessing;
+using Org.BouncyCastle.Cms;
+using Org.BouncyCastle.Crypto.Operators;
+using Org.BouncyCastle.Security;
+using Org.BouncyCastle.X509;
+using SignService.Services;
+
+internal static class PublicationSafetyTests
+{
+ public static async Task RunAsync(string tempRoot, DocumentSigner signer)
+ {
+ var root = Path.Combine(tempRoot, "publication-safety");
+ Directory.CreateDirectory(root);
+ using var a = Certificate("Original signer");
+ using var b = Certificate("Concurrent signer");
+ using var c = Certificate("Our signer");
+ var data = "publication regression"u8.ToArray();
+ var original = signer.Sign(data, a);
+ var newer = CmsMerger.Merge(new[] { original, signer.Sign(data, b) });
+
+ foreach (var append in new[] { false, true })
+ {
+ var dir = Directory.CreateDirectory(Path.Combine(root, append ? "append" : "replace")).FullName;
+ var document = Path.Combine(dir, "document.txt");
+ var signature = document + ".sig";
+ await File.WriteAllBytesAsync(document, data);
+ await File.WriteAllBytesAsync(signature, original);
+ await ExpectChanged(() => signer.SignFileAsync(document, c, new DocumentSigner.SignOptions
+ {
+ MergeWithExisting = append,
+ AfterSignatureInputsRead = () => File.WriteAllBytes(signature, newer),
+ }));
+ Assert(File.ReadAllBytes(signature).SequenceEqual(newer), "the actual input snapshot must protect a concurrent signer");
+ Assert(Directory.GetFiles(dir, "*.bak").Length == 0, "aborting must not produce a misleading backup");
+
+ await File.WriteAllBytesAsync(signature, original);
+ await ExpectChanged(() => signer.SignFileAsync(document, c, new DocumentSigner.SignOptions
+ {
+ MergeWithExisting = append, BeforePublish = () => File.Delete(signature),
+ }));
+ Assert(!File.Exists(signature), "an external deletion must not be undone");
+
+ var ok = await signer.SignFileAsync(document, c, new DocumentSigner.SignOptions { MergeWithExisting = append });
+ Assert(signer.VerifyDetached(data, File.ReadAllBytes(ok.SignaturePath)), "normal creation still works");
+ var replacement = await signer.SignFileAsync(document, a, new DocumentSigner.SignOptions { MergeWithExisting = append });
+ Assert(replacement.SignerCount == (append ? 2 : 1), "normal append/replacement still works");
+ Assert(append || replacement.BackupPath is not null, "intentional replacement retains its backup");
+ }
+
+ var appearingDoc = Path.Combine(root, "appearing.txt");
+ File.WriteAllBytes(appearingDoc, data);
+ await ExpectChanged(() => signer.SignFileAsync(appearingDoc, c, new DocumentSigner.SignOptions
+ {
+ BeforePublish = () => File.WriteAllBytes(appearingDoc + ".sig", newer),
+ }));
+ Assert(File.ReadAllBytes(appearingDoc + ".sig").SequenceEqual(newer), "a newly appeared destination must survive");
+
+ var missing = Path.Combine(root, "missing-signature.p7s");
+ await ExpectIo(() => signer.SignFileAsync(appearingDoc, c, new DocumentSigner.SignOptions
+ {
+ MergeWithExisting = true, ExistingSignaturePath = missing,
+ }));
+ Assert(!File.Exists(missing), "a missing selected file must not silently redirect publication");
+
+ var staged = Path.Combine(root, "staged.sig");
+ File.WriteAllBytes(staged, original);
+ await ExpectChanged(() => AtomicFile.WriteAsync(staged, signer.Sign(data, c), beforeCommit: () =>
+ {
+ File.WriteAllBytes(staged, newer);
+ DocumentSigner.ThrowIfSignatureChanged(original, File.ReadAllBytes(staged));
+ }));
+ Assert(File.ReadAllBytes(staged).SequenceEqual(newer), "last check must run after staging and before replacement");
+ Assert(!Directory.EnumerateFiles(root, ".signservice-*.tmp").Any(), "cancelled publication cleans its staging file");
+ Console.WriteLine("publication: one input snapshot, append/replace changes, deletion, appearance and staging guard: OK");
+
+ foreach (var extension in new[] { ".docx", ".pdf" })
+ foreach (var variant in new[] { ".sig", ".p7s", ".p7m", "odd", "direct", "missing-cert", "unrelated" })
+ {
+ var dir = Directory.CreateDirectory(Path.Combine(root, extension[1..] + "-" + variant.Replace('.', '-'))).FullName;
+ var document = Path.Combine(dir, "document" + extension);
+ CreateDocument(document);
+ var source = File.ReadAllBytes(document);
+ var copy = DocumentStamper.CreateStampedCopy(document, new[] { a }, new PdfStamper.StampOptions(), new DateTime(2025, 1, 1));
+ var snapshot = File.ReadAllBytes(copy);
+ var signaturePath = variant.StartsWith('.') ? copy + variant : Path.Combine(dir, "подпись с другим именем.p7s");
+ var signed = variant switch
+ {
+ "direct" => DirectSignature(snapshot, a),
+ "missing-cert" => WithoutCertificate(snapshot, a),
+ "unrelated" => signer.Sign("different document"u8.ToArray(), a),
+ _ => signer.Sign(snapshot, a),
+ };
+ File.WriteAllBytes(signaturePath, signed);
+ if (variant == "unrelated")
+ {
+ DocumentStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now);
+ Assert(!File.ReadAllBytes(copy).SequenceEqual(snapshot), "an unrelated signature must not prevent a fresh copy");
+ continue;
+ }
+
+ ExpectSignedCopy(() => DocumentStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now));
+ await ExpectSignedCopyAsync(() => signer.SignFileAsync(document, b, new DocumentSigner.SignOptions { Stamp = true }));
+ await ExpectSignedCopyAsync(() => signer.SignFileAsync(document, b, new DocumentSigner.SignOptions { Stamp = true, StampSignCopy = true }));
+ // Direct callers of the format stampers must also be protected at commit time.
+ ExpectSignedCopy(() => extension == ".pdf"
+ ? PdfStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now)
+ : WordStamper.CreateStampedCopy(document, new[] { b }, new PdfStamper.StampOptions(), DateTime.Now));
+ Assert(File.ReadAllBytes(copy).SequenceEqual(snapshot) && File.ReadAllBytes(document).SequenceEqual(source),
+ "guarded stamping must preserve both documents");
+ Assert(File.ReadAllBytes(signaturePath).SequenceEqual(signed), "guarded stamping must preserve the original signature");
+ Assert(!File.Exists(document + ".sig"), "preflight must stop signing before creating a new signature");
+ }
+ Console.WriteLine("publication: PDF/Word signed copies protected for sig/p7s/p7m, odd names, direct CMS and missing certificates: OK");
+ }
+
+ private static byte[] DirectSignature(byte[] data, X509Certificate2 certificate)
+ {
+ using var rsa = certificate.GetRSAPrivateKey()!;
+ var key = DotNetUtilities.GetRsaKeyPair(rsa);
+ var cert = new X509CertificateParser().ReadCertificate(certificate.RawData);
+ var generator = new CmsSignedDataGenerator();
+ generator.AddSignerInfoGenerator(new SignerInfoGeneratorBuilder().SetDirectSignature(true)
+ .Build(new Asn1SignatureFactory("SHA256WITHRSA", key.Private), cert));
+ generator.AddCertificates(Org.BouncyCastle.Utilities.Collections.CollectionUtilities.CreateStore(new[] { cert }));
+ return generator.Generate(new CmsProcessableByteArray(data), false).GetEncoded();
+ }
+
+ private static byte[] WithoutCertificate(byte[] data, X509Certificate2 certificate)
+ {
+ var cms = new SignedCms(new ContentInfo(data), true);
+ var signer = new CmsSigner(certificate) { IncludeOption = X509IncludeOption.None };
+ signer.SignedAttributes.Add(new Pkcs9SigningTime());
+ cms.ComputeSignature(signer);
+ return cms.Encode();
+ }
+
+ private static void CreateDocument(string path)
+ {
+ if (path.EndsWith(".pdf", StringComparison.Ordinal))
+ {
+ using var pdf = new PdfSharp.Pdf.PdfDocument();
+ pdf.AddPage();
+ pdf.Save(path);
+ return;
+ }
+ using var document = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document);
+ document.AddMainDocumentPart().Document = new Document(new Body(new Paragraph(new Run(new Text("Original content"))), new SectionProperties()));
+ }
+
+ private static X509Certificate2 Certificate(string name)
+ {
+ using var key = RSA.Create(2048);
+ return new CertificateRequest("CN=" + name, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
+ .CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddYears(1));
+ }
+
+ private static async Task ExpectChanged(Func operation)
+ {
+ try { await operation(); }
+ catch (IOException e) when (e.Message.Contains("изменился")) { return; }
+ throw new Exception("Concurrent publication must be rejected");
+ }
+
+ private static async Task ExpectIo(Func operation)
+ {
+ try { await operation(); }
+ catch (IOException) { return; }
+ throw new Exception("Missing input must be rejected");
+ }
+
+ private static void ExpectSignedCopy(Func operation)
+ {
+ try { operation(); }
+ catch (InvalidOperationException e) when (e.Message.Contains("уже подписана")) { return; }
+ throw new Exception("Signed copy must be protected");
+ }
+
+ private static async Task ExpectSignedCopyAsync(Func operation)
+ {
+ try { await operation(); }
+ catch (InvalidOperationException e) when (e.Message.Contains("уже подписана")) { return; }
+ throw new Exception("Signing must preserve the signed copy");
+ }
+
+ private static void Assert(bool condition, string message)
+ {
+ if (!condition) throw new Exception("Publication regression: " + message);
+ }
+}