Extract the kid calculation out of LoadSigningCredentialsInner
into a new internal static HostingExtensions.ComputeKid(string),
and cover it with five focused unit tests in
Yavsc.Org.Tests.ComputeKidTests.
The kid is the bit of signing-credential metadata that ties a
JWT to the right key in the JWKS. Without it, resource servers
(Yavsc.Blogs, Yavsc.Api) fail signature validation with IDX10500
'The signature key was not found', as fixed in 2c6d1157. That fix
inlined three lines of thumbprint-truncation logic at the top of
LoadSigningCredentialsInner, but left the calculation untested.
The tests in this commit pin its shape, value, stability, and
uniqueness, so a future refactor (e.g. switching from SHA-1 to
SHA-256, or moving to X509CertificateLoader for SYSLIB0057) has
to update them deliberately instead of silently changing the
JWKS key id.
Concretely:
- InternalsVisibleTo("Yavsc.Org.Tests") in AssemblyInfo.cs
gives the test project access to the new internal method
without forcing LoadSigningCredentialsInner to leak
further.
- ComputeKid(string) is the single source of truth for the
16-hex truncation; the production call site in
LoadSigningCredentialsInner now reads
'var kid = ComputeKid(certPath);'.
- The inline comment block is updated to say SHA-1 (which is
what X509Certificate2.GetCertHash() actually returns) instead
of the previous SHA-256 claim. The behaviour is unchanged.
- ComputeKid uses X509CertificateLoader.LoadCertificateFromFile
rather than the obsolete 'new X509Certificate2(string)' ctor
(SYSLIB0057); same on-disk behaviour, no obsolete warning.
Tests cover:
- 16-char upper-case hex output matching the first 16 hex
chars of the cert's GetCertHash();
- stability across repeated reads of the same cert;
- distinctness between two independently generated certs;
- the SHA-1 size of the underlying thumbprint (20 bytes), so
a future switch to SHA-256 forces a test update;
- CryptographicException propagation for a missing cert file
(Assert.ThrowsAny to stay portable across the Linux OpenSSL
and Windows leaf exception types).
165 lines
6.2 KiB
C#
165 lines
6.2 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Xunit;
|
|
using Yavsc.Extensions;
|
|
|
|
namespace Yavsc.Org.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="HostingExtensions.ComputeKid"/>, the
|
|
/// helper that derives the JWT <c>kid</c> header / JWKS key id
|
|
/// from the signing certificate. The kid is consumed by every
|
|
/// resource server (Yavsc.Blogs, Yavsc.Api) to match a token to
|
|
/// the right key in the JWKS, so getting its shape and stability
|
|
/// right is the whole point of the fix in commit 2c6d1157
|
|
/// (IDX10500 regression).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// We don't load the production cert (Let's Encrypt PEM + RSA
|
|
/// private key) — we generate throwaway self-signed certs in a
|
|
/// temp dir. The contract under test is the truncation /
|
|
/// encoding of the thumbprint, which is independent of the key
|
|
/// type and the cert issuer.
|
|
/// </remarks>
|
|
public class ComputeKidTests : IDisposable
|
|
{
|
|
private readonly string _tempDir;
|
|
|
|
public ComputeKidTests()
|
|
{
|
|
_tempDir = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"yavsc-compute-kid-tests-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_tempDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_tempDir, recursive: true); }
|
|
catch { /* best effort — Temp gets cleaned eventually */ }
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeKid_returns_first_16_hex_chars_of_cert_thumbprint()
|
|
{
|
|
var certPath = WriteSelfSignedCertRsa(out var expectedThumbHex);
|
|
|
|
var kid = HostingExtensions.ComputeKid(certPath);
|
|
|
|
// 16 hex chars = 64 bits, enough to be globally unique
|
|
// within a deployment and compact enough for a JWT header.
|
|
Assert.Equal(16, kid.Length);
|
|
Assert.True(
|
|
kid.All(c => "0123456789ABCDEF".Contains(c)),
|
|
$"kid '{kid}' contains non-uppercase-hex characters");
|
|
|
|
// Match the first 16 chars of the thumbprint exactly. We
|
|
// compute the expected value from the same cert the helper
|
|
// was given — no magic constants, no copy-paste of the
|
|
// truncation logic under test.
|
|
Assert.Equal(expectedThumbHex[..16], kid);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeKid_is_stable_across_repeated_reads()
|
|
{
|
|
var certPath = WriteSelfSignedCertRsa(out _);
|
|
|
|
var first = HostingExtensions.ComputeKid(certPath);
|
|
var second = HostingExtensions.ComputeKid(certPath);
|
|
var third = HostingExtensions.ComputeKid(certPath);
|
|
|
|
// Stability matters: a non-deterministic kid would
|
|
// invalidate tokens on every IdentityServer restart.
|
|
Assert.Equal(first, second);
|
|
Assert.Equal(second, third);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeKid_differs_between_distinct_certificates()
|
|
{
|
|
var certPathA = WriteSelfSignedCertRsa(out _);
|
|
var certPathB = WriteSelfSignedCertRsa(out _);
|
|
|
|
var kidA = HostingExtensions.ComputeKid(certPathA);
|
|
var kidB = HostingExtensions.ComputeKid(certPathB);
|
|
|
|
// Two independent RNG-drawn RSA keys will (in practice
|
|
// always) yield different thumbprints. A 64-bit truncated
|
|
// space has collisions at ~2^32 certs; we won't get there.
|
|
Assert.NotEqual(kidA, kidB);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeKid_uses_thumbprint_not_subject_or_serial()
|
|
{
|
|
// The previous fix-message claimed SHA-256; the helper
|
|
// actually reads X509Certificate2.GetCertHash() which is
|
|
// SHA-1. Pin that behaviour so a future refactor that
|
|
// switches to SHA-256 (or any other digest) is forced to
|
|
// update the test deliberately.
|
|
var certPath = WriteSelfSignedCertRsa(out var thumbHex);
|
|
|
|
var kid = HostingExtensions.ComputeKid(certPath);
|
|
|
|
// 16 hex chars is half of a 20-byte SHA-1 thumbprint.
|
|
// SHA-256 would be 32 bytes (64 hex chars) before
|
|
// truncation; SHA-1 is the only common digest whose
|
|
// hex encoding fits the 16-char prefix we observe.
|
|
Assert.Equal(20, thumbHex.Length / 2);
|
|
Assert.Equal(thumbHex[..16], kid);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeKid_propagates_cryptographic_exception_for_missing_file()
|
|
{
|
|
// The wrapper LoadSigningCredentials wraps this in an
|
|
// InvalidOperationException, but ComputeKid itself is a
|
|
// plain helper — it must surface the parser error so the
|
|
// wrapper can attach the cert path to the message. We
|
|
// assert against the base CryptographicException rather
|
|
// than the concrete subtype because the runtime picks
|
|
// different leaf types per platform (on Linux/OpenSSL we
|
|
// get Interop+Crypto+OpenSslCryptographicException, on
|
|
// Windows we'd get the older CryptographicException
|
|
// directly); the contract is the same either way.
|
|
var missing = Path.Combine(_tempDir, "does-not-exist.pem");
|
|
|
|
Assert.ThrowsAny<CryptographicException>(
|
|
() => HostingExtensions.ComputeKid(missing));
|
|
}
|
|
|
|
// --- helpers ----------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Generate a throwaway self-signed RSA-2048 cert, export it
|
|
/// as PEM to a file inside the test temp dir, and return the
|
|
/// path. The out parameter receives the upper-case hex form
|
|
/// of the cert's SHA-1 thumbprint so tests can pin the
|
|
/// expected kid without re-implementing the helper.
|
|
/// </summary>
|
|
private string WriteSelfSignedCertRsa(out string thumbHex)
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest(
|
|
"CN=yavsc-test",
|
|
rsa,
|
|
HashAlgorithmName.SHA256,
|
|
RSASignaturePadding.Pkcs1);
|
|
using var cert = req.CreateSelfSigned(
|
|
DateTimeOffset.UtcNow.AddDays(-1),
|
|
DateTimeOffset.UtcNow.AddYears(1));
|
|
|
|
// Capture the thumbprint before exporting — the cert is
|
|
// disposed by `using` and the exported PEM is what the
|
|
// helper will read.
|
|
thumbHex = Convert.ToHexString(cert.GetCertHash());
|
|
|
|
var path = Path.Combine(_tempDir, "cert-" + Guid.NewGuid().ToString("N") + ".pem");
|
|
File.WriteAllText(path, cert.ExportCertificatePem());
|
|
return path;
|
|
}
|
|
}
|