test(yavsc.org): cover the kid derivation in ComputeKid

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).
This commit is contained in:
Paul Schneider 2026-07-09 20:28:58 +01:00
commit 375e6482a6
3 changed files with 210 additions and 12 deletions

View file

@ -478,20 +478,19 @@ public static class HostingExtensions
// Validate the cert is readable (used downstream for token
// audience/subject validation; signing itself uses the key).
// Derive a stable KeyId from the certificate's SHA-256
// thumbprint. Without an explicit KeyId, IdentityServer emits
// JWTs without a 'kid' header and the JWKS without per-key
// identifiers, which breaks signature validation on resource
// servers (they cannot match a token to a key in the JWKS,
// they fail with IDX10500 "The signature key was not found").
// Truncating to 16 hex chars is enough to be globally unique
// within a deployment and keeps the JWT header compact. The
// thumbprint changes on cert renewal, which is the desired
// Derive a stable KeyId from the certificate's SHA-1
// thumbprint (the default for X509Certificate2.GetCertHash()).
// Without an explicit KeyId, IdentityServer emits JWTs without
// a 'kid' header and the JWKS without per-key identifiers,
// which breaks signature validation on resource servers (they
// cannot match a token to a key in the JWKS, they fail with
// IDX10500 "The signature key was not found"). Truncating the
// 40-hex-char SHA-1 to 16 hex chars is enough to be globally
// unique within a deployment and keeps the JWT header compact.
// The thumbprint changes on cert renewal, which is the desired
// behaviour: old tokens age out, resource servers refresh
// their JWKS cache for the new kid.
var certForKid = new X509Certificate2(certPath);
var certHash = certForKid.GetCertHash();
var kid = Convert.ToHexString(certHash)[..Math.Min(16, certHash.Length * 2)];
var kid = ComputeKid(certPath);
string keyPem = File.ReadAllText(keyPath);
@ -550,6 +549,34 @@ public static class HostingExtensions
}
}
/// <summary>
/// Derive the <c>kid</c> used to identify the signing key in the
/// JWT header and the JWKS. Takes the first 16 hex characters of
/// the certificate's SHA-1 thumbprint. See the inline rationale in
/// <see cref="LoadSigningCredentialsInner"/> for why this is
/// needed (IdentityServer8 + IDX10500).
/// </summary>
/// <remarks>
/// Internal so unit tests in <c>Yavsc.Org.Tests</c> can exercise
/// the truncation/encoding without going through the full PEM /
/// BouncyCastle pipeline. The input is a path rather than a
/// pre-loaded <see cref="X509Certificate2"/> to match the
/// production call site.
/// </remarks>
internal static string ComputeKid(string certPath)
{
// X509CertificateLoader is the .NET 9+ replacement for the
// obsolete `new X509Certificate2(string)` ctor (SYSLIB0057).
// Same on-disk format (PEM or DER), same thumbprint, just
// doesn't trip the obsolete-API warning at build time.
var certForKid = X509CertificateLoader.LoadCertificateFromFile(certPath);
var certHash = certForKid.GetCertHash();
// GetCertHash() returns a SHA-1 thumbprint (20 bytes, 40 hex
// chars). Truncating to 16 hex chars keeps the JWT header
// compact; Math.Min guards against an unexpected short hash.
return Convert.ToHexString(certHash)[..Math.Min(16, certHash.Length * 2)];
}
/// <summary>
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>