Recursive AI

Sign a file with CMS (PKCS#7) in C#

All onecrypto recipes

SignedData.Create writes a CMS signature, the format behind .p7s files, S/MIME and signed PDFs, and SignedData.Verify checks one and reports each rule it applied. The program below signs a file with a certificate and key made by OpenSSL, writes a detached .p7s, and verifies it.

Terminal
dotnet new console -o CmsSign && cd CmsSign
dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 365 \
  -subj "/CN=Release Signing" -addext "keyUsage=critical,digitalSignature" \
  -keyout signer-key.pem -out signer.pem
echo "release 1.4.2" > release.txt

OneCrypto is a preview, so dotnet add package needs --version or --prerelease. Put this in Program.cs and run dotnet run:

C#
using System.Security.Cryptography;
using RecursiveAI.OneCrypto;
 
using LoadedItems signerFile = Crypto.Load("signer.pem");
using LoadedItems keyFile = Crypto.Load("signer-key.pem");
Certificate cert = signerFile.Certificates[0];
 
// Sign: a detached signature carries the certificate but not the file itself
byte[] content = File.ReadAllBytes("release.txt");
var signer = new CmsSignerSpec
{
    Certificate = cert,
    Key = keyFile.PrivateKeys[0].Signing,
};
var options = new SignedDataOptions { Detached = true };
SignedData signed = SignedData.Create(content, [signer], options);
File.WriteAllBytes("release.txt.p7s", signed.Encoded.ToArray());
Console.WriteLine($"Wrote release.txt.p7s ({signed.Encoded.Length} bytes)");
 
// Verify: parse the .p7s and check it against the file
SignedData p7s = SignedData.Parse(File.ReadAllBytes("release.txt.p7s"));
SignedDataReport report = p7s.Verify(File.ReadAllBytes("release.txt"));
SignerReport s = report.Signers[0];
foreach (CmsCheck check in s.Checks)
    Console.WriteLine($"  {check.Rule,-20} {check.Status}");
 
// The report proves the signature matches the certificate inside the .p7s.
// Whether you trust that certificate is your decision: here, compare it with
// the one you expect, by fingerprint.
byte[] expected = cert.ComputeFingerprint(HashAlgorithmName.SHA256);
bool pinned = s.Certificate is not null && CryptographicOperations.FixedTimeEquals(
    s.Certificate.ComputeFingerprint(HashAlgorithmName.SHA256), expected);
Console.WriteLine($"Signature valid: {report.IsValid}, expected signer: {pinned}");
 
File.AppendAllText("release.txt", "tampered\n");
bool stillValid = p7s.Verify(File.ReadAllBytes("release.txt")).IsValid;
Console.WriteLine($"After tampering: {stillValid}");
File.WriteAllText("release.txt", "release 1.4.2\n");
Text
Wrote release.txt.p7s (822 bytes)
  signer-certificate   Passed
  digest-algorithm     Passed
  attributes           Passed
  content-type         Passed
  message-digest       Passed
  signature            Passed
  algorithm-protection NotApplicable
  key-usage            Passed
  key-strength         Passed
  signing-certificate  Passed
Signature valid: True, expected signer: True
After tampering: False

Each check names a rule from RFC 5652 or a related RFC (Reference gives the clause, Reason the detail when it fails). Leave Detached off to embed the content in the message; p7s.EncapsulatedContent then returns it. RSA, Ed25519 and Ed448 keys sign the same way, and SignStream and VerifyStream handle files larger than memory.

Check it with OpenSSL

OpenSSL verifies the same .p7s. Here it uses the signer’s own certificate as the trusted one:

Terminal
openssl cms -verify -binary -inform DER -in release.txt.p7s -content release.txt \
  -CAfile signer.pem -purpose any -out /dev/null 2>&1
Text
CMS Verification successful

Limits

  • Not audited. This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change.
  • Trust is not checked by default. Verify takes no clock and builds no certificate chain: a valid report says the signature is sound for the certificate in the message, nothing about who issued it. Check the signer yourself, as above. OneCrypto has an RFC 5280 path validator (SignedDataVerifyOptions.Chain), but it is a preview: the project says it is “not yet recommended for trust decisions”, so don’t rely on it to decide whom to trust yet (Path validation).
  • Speed. Against BouncyCastle 2.7.0, CMS signing takes 0.18 of its time and verifying 0.33 on x64 (geometric means; 0.34 and 0.18 on arm64), from the project’s benchmark table. ECDSA P-256 verification is the slowest row, 1.66× for a 1 KiB message on x64, because it calls the platform’s ECDSA (Conformance).

Every rule, limit and the OpenSSL test matrix: Cryptographic Message Syntax. To encrypt rather than sign: Encrypt and decrypt with CMS.