postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey

PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
  text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
  with trailing slash stripped) and DiscoveryUrl
  (ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
  surfaces the discovery URL before the call and suffixes it onto
  every error message, so reachability issues are diagnosable by
  pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
  now bounds the GetContextAsync wait at 5 minutes and calls both
  Stop() and Close() in the finally, so the listener is always
  released even if the user abandons the flow. Without this, the
  next PostIt launch fails with 'Failed to listen on prefix
  http://127.0.0.1:7890/ because it conflicts with an existing
  registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
  to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
  listener cleanup.

Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
  IdentityServer8 reserves that key and rejects the override with
  'Discovery custom entry jwks_uri cannot be added, because it
  already exists.' The default /.well-known/openid-configuration/jwks
  endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
  X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
  BouncyCastle-backed loader. The BCL path raised
  InvalidOperationException during AddSigningCredential and aborted
  the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
  the production EC Let's Encrypt cert. BouncyCastle 2.6.2
  PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
  path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
  ECParameters with the curve dispatched by NIST order bit length
  (256/384/521).
- Switch the signing credential handed to IdentityServer8 from
  X509Certificate2 to a SigningCredentials built from a SecurityKey
  (RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
  IdentityServer8's key material service reads cert.PrivateKey at
  runtime — on Linux that handle is not retained across the
  X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
  raised NullReferenceException on the first GET /jwks. The
  SecurityKey is a pure managed object whose Key is a live
  AsymmetricAlgorithm, which survives every read IdentityServer
  does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
  and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
  stack to stderr on failure, so future PEM-format issues surface
  in journalctl instead of being hidden behind SIGABRT.
This commit is contained in:
Paul Schneider 2026-06-21 07:30:11 +01:00
commit d62e59ba30
8 changed files with 380 additions and 32 deletions

View file

@ -1,6 +1,7 @@
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Google.Apis.Util.Store;
using IdentityModel;
@ -18,6 +19,15 @@ using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.OpenSsl;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Security;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
@ -350,31 +360,149 @@ public static class HostingExtensions
"Production IdentityServer requires a signing certificate. " +
"Configure Kestrel:Endpoints:Https:Certificate:{Path,KeyPath}.");
}
// CreateFromPemFile loads the leaf cert + its private key from
// PEM files without writing to the Windows certificate store
// (irrelevant on Linux, but keeps the call cross-platform).
var signingCert = X509Certificate2.CreateFromPemFile(certPath, keyPath);
// Pick the JWT signing algorithm from the cert's key type. Let's
// Encrypt may issue either RSA or ECDSA certificates depending on
// the ACME account's preferred chain; IdentityServer would 500 if
// we forced RS256 against an ECDSA key.
var algorithm = signingCert.GetECDsaPrivateKey() is not null ? "ES256" : "RS256";
identityServerBuilder.AddSigningCredential(signingCert, algorithm);
// Load the leaf cert and extract its private key for signing.
// The previous attempts (X509Certificate2.CreateFromPemFile,
// the 3-arg ctor with X509KeyStorageFlags, and BC + CopyWithPrivateKey)
// all loaded the cert successfully, but CreateJwkDocumentAsync
// still raised NullReferenceException on the first GET /jwks
// request: IdentityServer8's key material service reads the
// private key off the X509Certificate2 at runtime, and on Linux
// the key handle is not retained across that boundary.
//
// The reliable pattern is to pass a SigningCredentials that
// wraps a SecurityKey built directly from the BC-parsed key
// parameters. The SecurityKey is a managed object whose Key
// property is a live AsymmetricAlgorithm, which survives every
// read IdentityServer does (token signing, JWKS publish).
var signingCredentials = LoadSigningCredentials(certPath, keyPath);
identityServerBuilder.AddSigningCredential(signingCredentials);
}
// Override the advertised jwks_uri to the canonical
// /.well-known/jwks endpoint that UseIdentityServer() actually
// mounts. IdentityServer8's default convention here is
// /.well-known/openid-configuration/jwks, which is not what most
// OIDC clients (including IdentityModel.OidcClient) expect, and
// would otherwise need a parallel route to be wired up.
identityServerBuilder.Services.Configure<IdentityServer8.Configuration.IdentityServerOptions>(options =>
{
options.Discovery.CustomEntries["jwks_uri"] = "/.well-known/jwks";
});
// Note: IdentityServer8 does NOT expose the JWKS at /.well-known/jwks.
// The default jwks_uri is /.well-known/openid-configuration/jwks,
// which is what DiscoveryKeyEndpoint serves. Earlier revisions of
// this file tried to override CustomEntries["jwks_uri"], but
// IdentityServer8 reserves that key and rejects the override with
// "Discovery custom entry jwks_uri cannot be added, because it
// already exists." The default endpoint works once the signing
// credential's private key is attached (see above).
return identityServerBuilder;
}
/// <summary>
/// Load the signing credentials (algorithm + private key) from the
/// configured PEM files. Returns a <see cref="SigningCredentials"/>
/// whose <c>Key</c> is a managed <see cref="SecurityKey"/> built
/// directly from the BouncyCastle-parsed key parameters — which keeps
/// the private key alive for every read IdentityServer8 does (token
/// signing, JWKS publish), unlike <c>X509Certificate2.CopyWithPrivateKey</c>
/// which loses the handle on Linux when IdentityServer8's key material
/// service reads it back at runtime.
/// </summary>
private static SigningCredentials LoadSigningCredentials(string certPath, string keyPath)
{
// Pre-flight read so permission / missing-file errors surface with
// the actual path instead of being wrapped as an opaque
// InvalidOperationException by the cert / key parsers.
try
{
return LoadSigningCredentialsInner(certPath, keyPath);
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"[yavsc] Failed to load signing credentials from {certPath} / {keyPath}:");
Console.Error.WriteLine(ex.ToString());
throw new InvalidOperationException(
$"Failed to load signing credentials from {certPath} / {keyPath}. " +
"See stderr for the underlying managed exception (likely a " +
"PEM format mismatch between the cert and private key).",
ex);
}
}
private static SigningCredentials LoadSigningCredentialsInner(string certPath, string keyPath)
{
// Validate the cert is readable (used downstream for token
// audience/subject validation; signing itself uses the key).
_ = new X509Certificate2(certPath);
string keyPem = File.ReadAllText(keyPath);
// BouncyCastle's PemReader accepts every flavour of unencrypted
// private key PEM that ACME clients produce (PKCS#1 with
// BEGIN EC/RSA PRIVATE KEY, PKCS#8 with BEGIN PRIVATE KEY, both
// EC and RSA), and returns the right AsymmetricKeyParameter
// subtype without the SIGABRTs we saw when forcing the
// System.Security.Cryptography path on the production EC Let's
// Encrypt cert.
using var sr = new StringReader(keyPem);
var pemReader = new PemReader(sr);
var keyObj = pemReader.ReadObject()
?? throw new InvalidOperationException(
$"PEM reader returned null for {keyPath}");
AsymmetricKeyParameter bcKey = keyObj switch
{
AsymmetricCipherKeyPair pair => pair.Private,
AsymmetricKeyParameter param => param,
_ => throw new InvalidOperationException(
$"Unexpected PEM object type '{keyObj.GetType().FullName}' " +
$"in {keyPath}; expected a private key."),
};
// Build the SecurityKey + SigningCredentials. RsaSecurityKey /
// ECDsaSecurityKey wrap managed AsymmetricAlgorithm objects whose
// Key is the live private key — IdentityServer8 can call Sign on
// these repeatedly without losing the key handle.
switch (bcKey)
{
case RsaPrivateCrtKeyParameters rsa:
{
var rsaDotNet = DotNetUtilities.ToRSA(rsa);
var key = new RsaSecurityKey(rsaDotNet);
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
}
case ECPrivateKeyParameters ec:
{
var ecParams = new ECParameters
{
Curve = LoadEcCurve(ec.Parameters),
D = ec.D.ToByteArrayUnsigned(),
};
var ecdsa = ECDsa.Create();
ecdsa.ImportParameters(ecParams);
var key = new ECDsaSecurityKey(ecdsa);
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
}
default:
throw new InvalidOperationException(
$"Unsupported private key algorithm '{bcKey.GetType().Name}' " +
$"in {keyPath}; expected RSA or EC.");
}
}
/// <summary>
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>
/// understands. Handles the curves Let's Encrypt issues (P-256,
/// P-384, P-521); other curves throw.
/// </summary>
private static ECCurve LoadEcCurve(ECDomainParameters bcCurve)
{
// bcCurve.N is the order of the generator; its bit length is the
// canonical fingerprint for NIST curves (256, 384, 521 bits).
var orderBits = bcCurve.N.BitLength;
return orderBits switch
{
256 => ECCurve.NamedCurves.nistP256,
384 => ECCurve.NamedCurves.nistP384,
521 => ECCurve.NamedCurves.nistP521,
_ => throw new InvalidOperationException(
$"Unsupported EC curve with order bit length {orderBits}; " +
"expected P-256, P-384 or P-521."),
};
}
private static bool UsesInMemoryProvider(string connectionString)
{
return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase);