Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 48 additions & 15 deletions src/oao.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading