diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f50ea6..63e3f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ Design specs and implementation plans in `superpowers/specs/` and `superpowers/p ## [Unreleased] +### Fixed (2026-06-04 — net10 CI follow-up) +- **Data-Protection certificate provisioning is now race-safe.** + `Program.ConfigurePlatformKeyProtection` writes `dp-key-protection.pfx` + atomically (unique temp file + `File.Move`) and retries transient + IO/crypto errors, tolerating a concurrent writer by loading the winner's + file. Eliminates the intermittent `File.WriteAllBytes` failure that + surfaced under net10 parallel integration tests — each + `WebApplicationFactory` writes its own cert, and an AV/Defender scan of a + freshly written `.pfx` under load could briefly lock it. Production builds + this once per process and is unaffected. (Caught hanging Dependabot PRs + #34/#35.) +- **Master branch-protection ruleset corrected to unblock Dependabot.** + Required status checks swapped the two CodeQL per-language contexts + (`Analyze (csharp)`, `Analyze (actions)`) — which CodeQL default-setup does + not emit on Dependabot PRs — for the aggregate `CodeQL` check (GHAS app + 57789). Dependabot PRs were sitting permanently `BLOCKED` despite green + checks; mirrors the control-menu fix. API-only change to ruleset + `16570488`; `strict_required_status_checks_policy` and all other rules + preserved. + ### Breaking changes - Existing installs cannot upgrade in place. Delete any previous local DB + clear browser cookies, then re-run Setup. The rename changes the diff --git a/src/oao.Web/Program.cs b/src/oao.Web/Program.cs index 7a2d166..6223e06 100644 --- a/src/oao.Web/Program.cs +++ b/src/oao.Web/Program.cs @@ -255,22 +255,55 @@ public partial class Program internal static void ConfigurePlatformKeyProtection(IDataProtectionBuilder dpBuilder, string dpKeysPath) { var certPath = Path.Combine(dpKeysPath, "dp-key-protection.pfx"); - X509Certificate2 cert; - if (File.Exists(certPath)) - { - cert = X509CertificateLoader.LoadPkcs12FromFile(certPath, null); - } - else + dpBuilder.ProtectKeysWithCertificate(LoadOrCreateProtectionCert(certPath)); + } + + // Loads the Data-Protection certificate, creating it on first run. Resilient to + // transient IO contention: parallel integration-test hosts (one WebApplicationFactory + // each) and AV/Defender scanners can briefly lock a freshly written `.pfx`, which made + // CI intermittently fail on `File.WriteAllBytes`. Production builds this once per process. + private static X509Certificate2 LoadOrCreateProtectionCert(string certPath) + { + const int maxAttempts = 8; + for (var attempt = 1; ; attempt++) { - using var rsa = RSA.Create(2048); - var req = new CertificateRequest( - "CN=oao-DataProtection", - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(10)); - File.WriteAllBytes(certPath, cert.Export(X509ContentType.Pfx)); + try + { + if (File.Exists(certPath)) + return X509CertificateLoader.LoadPkcs12FromFile(certPath, null); + + using var rsa = RSA.Create(2048); + var req = new CertificateRequest( + "CN=oao-DataProtection", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using var created = req.CreateSelfSigned( + DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(10)); + + // Atomic publish: write a uniquely-named temp file then move it into place, + // so a concurrent reader never sees a half-written `.pfx`. A losing racer's + // Move throws (the target already exists); the next pass loads the winner's file. + var tmp = certPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllBytes(tmp, created.Export(X509ContentType.Pfx)); + try + { + File.Move(tmp, certPath); + } + catch + { + try { File.Delete(tmp); } catch { /* best effort */ } + throw; + } + + return X509CertificateLoader.LoadPkcs12FromFile(certPath, null); + } + catch (Exception ex) when ( + attempt < maxAttempts && + ex is IOException or UnauthorizedAccessException or CryptographicException) + { + System.Threading.Thread.Sleep(40 * attempt); + } } - dpBuilder.ProtectKeysWithCertificate(cert); } }