Digital Signatures: RSA-PSS, ECDSA, Verification and a Signed License File
When the verifying side must not be able to sign, as with a license check running on a customer's server, you use a digital signature rather than a MAC: the private key signs and the public key shipped with the application only verifies. With real outputs we cover RSA-PSS versus PKCS#1 v1.5, why the ECDSA nonce matters, P1363 and DER formats, canonicalization, Bookstore's signed license file with keyId rotation, and the status of Ed25519 in .NET.
Version note: The code in this post was built and run with .NET 10 and xUnit v3 4.0; the outputs were taken on Ubuntu 24.04 (OpenSSL 3.0).
Guess First
Some of Bookstore's institutional customers, schools with limited internet access for example, run the catalog and purchase approval modules offline on their own servers. A license file decides how many units and how many users the installation runs with. The first design: the license is a JSON file with the JSON's HMAC next to it; the HMAC key is embedded in the application, which recomputes the HMAC at startup and compares.
The tests pass and a hand-edited license is rejected. What did we miss in this design? The key needed to verify is the same key needed to produce a license. Every server the application is installed on has that key; anyone who pulls it out with a decompiler can produce a valid license allowing as many users as they like. The verifying side must not be able to sign, and that's a property a digital signature provides, not a MAC. In this post we set up signing with RSA and ECDSA, signature formats, the question "which bytes am I signing?", and Bookstore's signed license file.
The Difference Between a Signature and a MAC
With a MAC both parties share the same secret; anyone who can produce the MAC can verify it, and anyone who can verify it can produce it. With a digital signature the signature is produced with the private key and verified with the public key. Only the public key goes into the application; the private key stays with Bookstore. Let's see it in code:
using ECDsa issuer = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using ECDsa shipped = ECDsa.Create();
shipped.ImportFromPem(issuer.ExportSubjectPublicKeyInfoPem());
byte[] data = "license"u8.ToArray();
byte[] signature = issuer.SignData(data, HashAlgorithmName.SHA256);
Console.WriteLine($"verify with the shipped public key: {shipped.VerifyData(data, signature, HashAlgorithmName.SHA256)}");
try
{
shipped.SignData(data, HashAlgorithmName.SHA256);
}
catch (CryptographicException ex)
{
Console.WriteLine($"sign with the shipped public key: {ex.GetType().Name}");
}
$ dotnet run --project src/Bookstore.CryptoLab -- public-key-sign
verify with the shipped public key: True
sign with the shipped public key: OpenSslCryptographicException
The public key shipped with the application verifies but can't sign. The exception type's name comes from OpenSSL on Linux; on Windows the type differs, but it's still a CryptographicException. Going back to the table in the first post of the series, non-repudiation comes from here too: since only the holder of the private key can produce a license, you can't say "we didn't issue this license".
RSA-PSS and PKCS#1 v1.5 Signatures
When signing with RSA you also pick a padding. There are two options: PKCS#1 v1.5 signature padding and PSS. In the previous post we said to avoid v1.5 as an encryption padding; v1.5 on the signature side isn't broken in the same way, and it's still used in common algorithms such as RS256 in JWT. But if you control both sides, choose PSS: its design is sounder and its security proof stronger.
byte[] pss1 = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
byte[] pss2 = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
byte[] v15a = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
byte[] v15b = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
$ dotnet run --project src/Bookstore.CryptoLab -- signatures
RSA-3072 PSS : 384 bytes, two signatures equal: False
RSA-3072 PKCS#1 1.5: 384 bytes, two signatures equal: True
ECDSA P-256, 1000 signatures: IEEE P1363 lengths [64], DER lengths [69, 70, 71, 72]
P1363 signature verified as DER: False
PSS produces a different signature for the same data every time, while v1.5 is deterministic. That has a practical consequence: don't use the signature as a cache key or as a "was this document already signed?" check; with PSS two signatures of the same document aren't equal. Both are 384 bytes with RSA-3072; compare that with ECDSA's 64 bytes.
ECDSA and Why the Nonce Is Critical
ECDSA uses a secret, single-use random number for every signature. If that number repeats across two signatures or is predictable, the private key itself can be computed from two signatures. In 2010 a games console maker kept this number fixed when signing firmware and its signing key came out because of it; that's the best-known example of this mistake.
In .NET you never touch this number: SignData leaves it to the platform's crypto library, CNG or OpenSSL. That's one lesson of this post: don't write ECDSA with your own math or with a small library you found because it's "faster". Like the GCM nonce in the third post, a single repeat breaks everything here too; the difference is that this time what you lose is the signing key, not one message.
Signature Formats: IEEE P1363 and DER
An ECDSA signature consists of two numbers, r and s. There are two common ways to turn them into bytes. IEEE P1363, a fixed-length format that puts the two numbers side by side, is always 64 bytes for P-256. DER wraps the two numbers in a structure; the length changes with the leading zero bytes of the numbers. In the output above, across 1000 signatures P1363 is always 64 bytes and DER is between 69 and 72 bytes.
In .NET you say which one you want with the DSASignatureFormat parameter. The default is P1363; X.509 certificates and CMS use DER, JWS uses P1363. Verifying with the wrong format doesn't throw, it just returns false; the last line of the output shows that. If you exchange signatures with another system, write the format explicitly into the agreement; a good share of "the signature doesn't verify" bugs come from the wrong format.
Canonicalization: Which Bytes Are You Signing?
A signature covers bytes, not meaning. {"maxUsers":50} and { "maxUsers": 50 } carry the same data but are different bytes; the signature of one doesn't verify the other. There are two ways to sign JSON. The first is bringing the JSON into a canonical form before signing (canonicalization): strict rules for field order, whitespace, number and string formatting. That path is complex and depends on both sides applying the same rules exactly. The second is carrying the bytes as they are: the signer serializes the JSON once, turns those bytes into text with Base64Url and signs it; the verifier checks the same text against the signature and only then parses the JSON. Nothing is serialized again.
JWS uses the second way: header.payload.signature. JWS details and algorithm choice were covered in the JWT post of the Authentication and Authorization series. Our license format applies the same idea with a smaller surface: there's no algorithm field because the algorithm is fixed, so there's also no risk of talking the verifier into accepting another algorithm.
The Signed License File
Bookstore, the online bookstore used as the shared example across this site's series, keeps the license code in the Licensing folder of the Bookstore.Crypto library. A license is a single line of text in four parts: the format name, the identifier of the signing key, the payload and the signature. The signature covers the whole of the first three parts, so changing the keyId breaks the signature too:
// License text: "BSL1.<keyId>.<payload>.<signature>", payload and signature in Base64Url.
// The signature covers "BSL1.<keyId>.<payload>" exactly as written: nothing is re-serialized.
public static class LicenseFormat
{
public const string Prefix = "BSL1";
internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
internal static byte[] SigningInput(string keyId, string payload) =>
Encoding.ASCII.GetBytes($"{Prefix}.{keyId}.{payload}");
}
public sealed class LicenseIssuer(ECDsa signingKey, string keyId)
{
public string Issue(LicenseClaims claims)
{
string payload = Base64Url.EncodeToString(JsonSerializer.SerializeToUtf8Bytes(claims, LicenseFormat.Json));
byte[] signature = signingKey.SignData(
LicenseFormat.SigningInput(keyId, payload), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return $"{LicenseFormat.Prefix}.{keyId}.{payload}.{Base64Url.EncodeToString(signature)}";
}
}
The verifier runs inside the application and knows only public keys. Order matters: first the format, then the key, then the signature, and last the content and the dates:
public LicenseCheck Verify(string licenseText)
{
string[] parts = licenseText.Trim().Split('.');
if (parts.Length != 4 || parts[0] != LicenseFormat.Prefix)
{
return new(LicenseStatus.Malformed);
}
if (!publicKeys.TryGetValue(parts[1], out ECDsa? publicKey))
{
return new(LicenseStatus.UnknownKey);
}
byte[] signature;
try
{
signature = Base64Url.DecodeFromChars(parts[3]);
}
catch (FormatException)
{
return new(LicenseStatus.Malformed);
}
// First the signature over the bytes as received, only then the JSON
if (!publicKey.VerifyData(LicenseFormat.SigningInput(parts[1], parts[2]), signature,
HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
{
return new(LicenseStatus.BadSignature);
}
LicenseClaims? claims = JsonSerializer.Deserialize<LicenseClaims>(Base64Url.DecodeFromChars(parts[2]), LicenseFormat.Json);
if (claims is null)
{
return new(LicenseStatus.Malformed);
}
DateTimeOffset now = time.GetUtcNow();
if (now < claims.IssuedAt)
{
return new(LicenseStatus.NotYetValid, claims);
}
return now >= claims.ExpiresAt ? new(LicenseStatus.Expired, claims) : new(LicenseStatus.Valid, claims);
}
The reason for verifying the signature before the JSON is never to hand unverified input to the parser; a bug in the parser or an unexpected field shouldn't open an attack surface before verification. The lab command issues a license and tries six different cases. For key rotation the application holds two public keys: bookstore-license-2026 and bookstore-license-2027:
$ dotnet run --project src/Bookstore.CryptoLab -- license
license file: 486 chars
BSL1.bookstore-license-2026.eyJsaWNlbnNlSWQiOiJMSUMt...2YWwiXX0.wurMscyRQMXT...
payload: {"licenseId":"LIC-2026-0042","institutionId":"0197f001-0000-7000-8000-00000000a001","insti...
as issued : Valid (LIC-2026-0042, 50 users)
maxUsers 50 -> 500 : BadSignature
same claims re-serialized : BadSignature
keyId switched to 2027 : BadSignature
keyId unknown : UnknownKey
checked in October 2027 : Expired (LIC-2026-0042, 50 users)
renewed with the 2027 key : Valid (LIC-2027-0042, 50 users)
Let's read the lines one by one. Raising the user count from 50 to 500 breaks the signature. Parsing the same claims and serializing them again as indented JSON breaks the signature too; that's the problem from the canonicalization section, and our format solves it by never re-serializing. Switching the keyId to the 2027 key becomes an attempt to verify with the wrong key instead of the right one, and it's rejected. For an expired license we return Expired but also hand over the claims, so the application can show a meaningful message such as "your license expired on 1 September 2027". On the last line, next year's license is signed with the new key, and since the application already holds that key's public key, it's accepted.
Beyond the cases the lab shows, the tests lock down two more important things: a forged license produced with another private key but carrying the same keyId is rejected, and trying to issue a license with only the public key throws:
[Fact]
public void Signature_from_another_private_key_is_rejected()
{
using ECDsa attacker = ECDsa.Create(ECCurve.NamedCurves.nistP256);
string forged = new LicenseIssuer(attacker, "test-2026").Issue(Claims with { MaxUsers = 5000 });
Assert.Equal(LicenseStatus.BadSignature, Verify(forged).Status);
}
$ dotnet test
Test run summary: Passed!
total: 46
failed: 0
succeeded: 46
skipped: 0
duration: 3s 823ms
Ed25519 and EdDSA: Status in the BCL
In modern protocols Ed25519, an EdDSA signature defined on a curve from the Curve25519 family, is increasingly taking ECDSA's place: the signature is deterministic, so the nonce problem above doesn't exist by design; keys and signatures are small, fast, and easier to implement correctly. .NET's own crypto library, however, has no class for Ed25519 signatures. While writing this I checked by listing the public types in the .NET 10 and the newly released .NET 11 runtimes with reflection: .NET 11 added key agreement with X25519, but EdDSA signatures are in neither version.
If you need Ed25519, for example because the other side only accepts it, you have to pick a third-party library. Two common options: NSec.Cryptography, which wraps libsodium, and BouncyCastle.Cryptography, written fully in managed code (latest stable version 2.7.0, July 2026, MIT licensed). The criteria are the same as for the Argon2id choice in the second post: native dependency or managed code, continuity of maintenance, vulnerability history, and inclusion in dependency scanning. If you control both sides, ECDSA with P-256 works on every platform with no extra dependency; that's our choice for Bookstore's license.
Production Notes
The signing private key is the most valuable part of this system: if it leaks, anyone can issue any license they want, and the only way back is distributing a new public key to every installation. So never take the private key out of the service that issues licenses; if possible use an HSM or Key Vault's signing operation. Since there are few licenses, signing taking a few seconds is no problem.
Design key rotation from the start. If the application already carries next year's public key, there's no need to update installations when the old key is retired; only new licenses are signed with the new key. If a key leaks, though, you have to ship an update that rejects that keyId; since that update takes time to reach offline installations, keep the leak risk low from the start.
On an offline system time isn't trustworthy: someone who turns the server's clock back can make an expired license look valid. You can't prevent this completely, but you can make it harder, for example by storing the latest time the application has seen and warning if the clock goes back from there. Likewise, the signature protects the license's integrity, not the application itself; someone who modifies the application's binary to skip the license check can't be stopped by a signature. The purpose of the license signature is to stop an honest customer from changing the license by accident or on purpose and to reflect the contract technically; on its own it's not a defense against reverse engineering.
Log the verification result but not the whole license: the LicenseId, the keyId and the result are enough. If BadSignature keeps showing up on an installation, either the file is corrupt or someone is trying to change the license; the support team needs to know.
Common Pitfalls
The first pitfall is the one from Guess First: embedding a symmetric key in the application for verification. If the verifying side isn't trusted, for example because it runs on the customer's server, use a signature, not a MAC.
The second is using the content before verifying the signature: even looking at expiresAt in the license and saying "it's expired, no need to check the signature" is a decision made in the wrong order. Always the signature first.
The third is reproducing the signed bytes: verifying by parsing the JSON and serializing it again. As soon as field order, whitespace or number formatting changes, a valid license is rejected; worse, with JSON that two different parsers interpret differently, what was signed and what is used can differ.
The fourth is mixing up signature formats: sending a P1363 signature to a system that expects DER or the other way round. Because you get false instead of an error, the problem looks like "invalid signature"; write the format explicitly in code and documentation.
The fifth is reading the algorithm from the input. If the license text had a field saying "verify with this algorithm", an attacker could try changing it to a weaker algorithm or a MAC; the JWT world has known examples of this class. Fix the algorithm together with the key; let the keyId only pick which public key to use.
A Small Experiment
★☆☆ Change LicenseIssuer to sign in DER format while LicenseVerifier still expects P1363. Which test broke, and is the result BadSignature or an exception?
★★☆ Add a "clock going backwards" check to license verification: the verifier stores the latest time it has seen in a file and returns a new ClockRolledBack status if the time from TimeProvider is more than 24 hours behind it. Show it with a test.
★★★ Generalize the same license format to work with RSA-PSS: build a structure that picks the right verifier by keyId (one for P-256 ECDSA, one for RSA-3072 PSS), but reads the algorithm from a keyId → algorithm mapping embedded in the application, not from the license text. Test that a license putting an ECDSA signature under an RSA key identifier is rejected.
With this post the series' core primitives are complete: hashes and MACs, symmetric encryption, asymmetric encryption and key agreement, and digital signatures. In the next post we move to the structure that proves who these public keys belong to: X.509 certificates, chain validation and mTLS in ASP.NET Core.