feat(site-runtime): CertStoreActor exports trusted certs (thumbprint+DER) for node reconciliation (UA1 groundwork)

This commit is contained in:
Joseph Doherty
2026-07-09 01:15:51 -04:00
parent 667d863e58
commit 39976772d9
3 changed files with 87 additions and 0 deletions
@@ -82,6 +82,30 @@ public record RemoveCertFromLocalStore(string Thumbprint);
/// <summary>Per-node: enumerate the local trusted-peer and rejected stores.</summary>
public record ListLocalCerts();
/// <summary>
/// Per-node: export the trusted-peer store contents (thumbprint + raw DER bytes)
/// so the Deployment Manager singleton can reconcile the trusted set onto a
/// (re)joining site node. Trusted store only — rejected certs are never pushed.
/// </summary>
public record ExportLocalTrustedCerts();
/// <summary>
/// One exported trusted certificate: its thumbprint (store filename key) and the
/// raw DER bytes needed to re-write it into another node's trusted-peer store.
/// </summary>
/// <param name="Thumbprint">The certificate thumbprint.</param>
/// <param name="DerBytes">The certificate's raw DER encoding.</param>
public record ExportedCert(string Thumbprint, byte[] DerBytes);
/// <summary>
/// Per-node reply to <see cref="ExportLocalTrustedCerts"/>: the local trusted-peer
/// store's certificates, or a failure with the IO error.
/// </summary>
/// <param name="Success">True if the trusted store was enumerated successfully.</param>
/// <param name="Error">The error message on failure, otherwise null.</param>
/// <param name="Certs">Exported trusted certificates, empty on failure.</param>
public record LocalCertExport(bool Success, string? Error, IReadOnlyList<ExportedCert> Certs);
/// <summary>
/// Per-node ack for a <see cref="WriteCertToLocalStore"/>, <see cref="RemoveCertFromLocalStore"/>
/// or <see cref="ListLocalCerts"/> operation.
@@ -51,6 +51,7 @@ public class CertStoreActor : ReceiveActor
Receive<WriteCertToLocalStore>(HandleWrite);
Receive<RemoveCertFromLocalStore>(HandleRemove);
Receive<ListLocalCerts>(_ => HandleList());
Receive<ExportLocalTrustedCerts>(_ => HandleExport());
}
/// <summary>
@@ -133,6 +134,47 @@ public class CertStoreActor : ReceiveActor
}
}
/// <summary>
/// Exports the trusted-peer store (thumbprint + raw DER) so the Deployment
/// Manager singleton can reconcile the trusted set onto a (re)joining node.
/// Trusted store only — rejected certs are never pushed to peers.
/// </summary>
private void HandleExport()
{
try
{
var certs = new List<ExportedCert>();
if (Directory.Exists(_trustedStoreDir))
{
foreach (var file in Directory.EnumerateFiles(_trustedStoreDir)
.Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)))
{
try
{
var der = File.ReadAllBytes(file);
// Load only to derive the canonical thumbprint (the store
// filename); the DER bytes shipped are the raw file bytes.
using var cert = X509CertificateLoader.LoadCertificate(der);
certs.Add(new ExportedCert(cert.Thumbprint, der));
}
catch (Exception ex)
{
// A malformed file must not abort the whole export.
_log.Warning(ex, "Skipping unreadable certificate file {File} during export", file);
}
}
}
Sender.Tell(new LocalCertExport(true, null, certs));
}
catch (Exception ex)
{
_log.Warning(ex, "Failed to export trusted certificates from PKI store");
Sender.Tell(new LocalCertExport(false, ex.Message, Array.Empty<ExportedCert>()));
}
}
private IEnumerable<TrustedCertInfo> EnumerateStore(string storeDir, bool rejected)
{
if (!Directory.Exists(storeDir))
@@ -106,6 +106,27 @@ public class CertStoreActorTests : TestKit, IDisposable
emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint);
}
[Fact]
public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes()
{
var (derBase64, thumbprint) = BuildSelfSignedCert();
var expectedDer = Convert.FromBase64String(derBase64);
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
// Seed one trusted cert via the normal write path.
actor.Tell(new WriteCertToLocalStore(derBase64, thumbprint));
ExpectMsg<LocalCertOpAck>().Success.Should().BeTrue();
// Export — thumbprint + raw DER bytes come back for reconciliation.
actor.Tell(new ExportLocalTrustedCerts());
var export = ExpectMsg<LocalCertExport>();
export.Success.Should().BeTrue();
export.Error.Should().BeNull();
var entry = export.Certs.Should().ContainSingle().Subject;
entry.Thumbprint.Should().Be(thumbprint);
entry.DerBytes.Should().Equal(expectedDer);
}
[Fact]
public void Write_InvalidBase64_ReturnsFailureAck()
{