feat: add OCSP peer reject and chain validation events (Gap 10.10)

Add OcspChainValidationEvent DTO, OcspStatus enum, and OcspEventBuilder
helper with BuildPeerReject, BuildChainValidation, and ParseStatus methods.
Register OcspChainValidationEvent in EventJsonContext source-gen context.
Add OcspPeerReject and OcspChainValidation subject constants to EventSubjects.
10 new tests in OcspEventTests cover all DTOs, builder methods, status
parsing, and JSON round-trip.
This commit is contained in:
Joseph Doherty
2026-02-25 13:15:44 -05:00
parent b314e3f510
commit 4ba87c4175
4 changed files with 385 additions and 0 deletions

View File

@@ -11,4 +11,5 @@ namespace NATS.Server.Events;
[JsonSerializable(typeof(LameDuckEventMsg))]
[JsonSerializable(typeof(AuthErrorEventMsg))]
[JsonSerializable(typeof(OcspPeerRejectEventMsg))]
[JsonSerializable(typeof(OcspChainValidationEvent))]
internal partial class EventJsonContext : JsonSerializerContext;

View File

@@ -40,6 +40,11 @@ public static class EventSubjects
// Inbox for responses
public const string InboxResponse = "$SYS._INBOX_.{0}";
// OCSP advisory events
// Go reference: ocsp.go — OCSP peer reject and chain validation subjects.
public const string OcspPeerReject = "$SYS.SERVER.{0}.OCSP.PEER.REJECT";
public const string OcspChainValidation = "$SYS.SERVER.{0}.OCSP.CHAIN.VALIDATION";
// JetStream advisory events
// Go reference: jetstream_api.go advisory subjects
public const string JsAdvisoryStreamCreated = "$JS.EVENT.ADVISORY.STREAM.CREATED.{0}";

View File

@@ -540,6 +540,119 @@ public sealed class OcspPeerRejectEventMsg
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// OCSP chain validation advisory, published when a certificate's OCSP status
/// is checked during TLS handshake.
/// Go reference: ocsp.go — OCSP peer verification advisory.
/// </summary>
public sealed class OcspChainValidationEvent
{
public const string EventType = "io.nats.server.advisory.v1.ocsp_chain_validation";
[JsonPropertyName("type")]
public string Type { get; init; } = EventType;
[JsonPropertyName("id")]
public string Id { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("time")]
public string Time { get; init; } = DateTime.UtcNow.ToString("O");
[JsonPropertyName("server")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventServerInfo? Server { get; set; }
[JsonPropertyName("cert_subject")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CertSubject { get; set; }
[JsonPropertyName("cert_issuer")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CertIssuer { get; set; }
[JsonPropertyName("serial_number")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SerialNumber { get; set; }
/// <summary>OCSP status string: "good", "revoked", or "unknown".</summary>
[JsonPropertyName("ocsp_status")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? OcspStatus { get; set; }
[JsonPropertyName("checked_at")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? CheckedAt { get; set; }
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; set; }
}
/// <summary>
/// OCSP status values matching the Go OCSP response status strings.
/// Go reference: ocsp.go — ocspStatusGood, ocspStatusRevoked, ocspStatusUnknown.
/// </summary>
public enum OcspStatus
{
Unknown,
Good,
Revoked,
}
/// <summary>
/// Factory helpers for constructing OCSP advisory event messages.
/// Go reference: ocsp.go — OCSP peer reject and chain validation advisory publishing.
/// </summary>
public static class OcspEventBuilder
{
/// <summary>
/// Build an OcspPeerRejectEventMsg for a rejected peer certificate.
/// Go reference: ocsp.go — postOCSPPeerRejectEvent.
/// </summary>
public static OcspPeerRejectEventMsg BuildPeerReject(
string serverId, string serverName,
string kind, string reason) =>
new()
{
Id = EventBuilder.GenerateEventId(),
Time = DateTime.UtcNow,
Server = new EventServerInfo { Id = serverId, Name = serverName },
Kind = kind,
Reason = reason,
};
/// <summary>
/// Build an OcspChainValidationEvent for a certificate OCSP check.
/// Go reference: ocsp.go — OCSP chain validation advisory.
/// </summary>
public static OcspChainValidationEvent BuildChainValidation(
string serverId, string serverName,
string certSubject, string certIssuer, string serialNumber,
string ocspStatus, string? error = null) =>
new()
{
Server = new EventServerInfo { Id = serverId, Name = serverName },
CertSubject = certSubject,
CertIssuer = certIssuer,
SerialNumber = serialNumber,
OcspStatus = ocspStatus,
CheckedAt = DateTime.UtcNow,
Error = error,
};
/// <summary>
/// Parse an OCSP status string into the <see cref="OcspStatus"/> enum.
/// Go reference: ocsp.go — ocspStatusGood / ocspStatusRevoked string constants.
/// </summary>
public static OcspStatus ParseStatus(string? status) =>
status?.ToLowerInvariant() switch
{
"good" => OcspStatus.Good,
"revoked" => OcspStatus.Revoked,
_ => OcspStatus.Unknown,
};
}
/// <summary>
/// Remote server shutdown advisory.
/// Go reference: events.go — remote server lifecycle.

View File

@@ -0,0 +1,266 @@
// Port of Go server/ocsp_test.go — OCSP peer reject and chain validation advisory tests.
// Go reference: golang/nats-server/server/ocsp.go — postOCSPPeerRejectEvent,
// OCSP chain validation advisory publishing (Gap 10.10).
//
// Tests cover: OcspPeerRejectEventMsg, OcspChainValidationEvent, OcspEventBuilder,
// OcspStatus enum, subject constants, and JSON serialisation round-trip.
using System.Text.Json;
using NATS.Server.Events;
namespace NATS.Server.Tests.Events;
/// <summary>
/// Tests for <see cref="OcspPeerRejectEventMsg"/>, <see cref="OcspChainValidationEvent"/>,
/// <see cref="OcspEventBuilder"/>, and the <see cref="OcspStatus"/> enum.
/// Go reference: ocsp.go — OCSP peer verification advisory events (Gap 10.10).
/// </summary>
public class OcspEventTests
{
// ========================================================================
// OcspPeerRejectEventMsg
// Go reference: ocsp.go postOCSPPeerRejectEvent
// ========================================================================
/// <summary>
/// EventType constant must match the Go advisory type string.
/// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_peer_reject".
/// </summary>
[Fact]
public void OcspPeerRejectEventMsg_HasCorrectEventType()
{
// Go reference: ocsp.go — advisory type constant.
OcspPeerRejectEventMsg.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_peer_reject");
var ev = new OcspPeerRejectEventMsg();
ev.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
}
/// <summary>
/// Each OcspPeerRejectEventMsg gets its own unique non-empty Id.
/// Go reference: ocsp.go — nuid.Next() generates a unique event ID per advisory.
/// </summary>
[Fact]
public void OcspPeerRejectEventMsg_GeneratesUniqueId()
{
// Go reference: ocsp.go — each advisory is assigned a unique ID.
var ev1 = new OcspPeerRejectEventMsg { Id = EventBuilder.GenerateEventId() };
var ev2 = new OcspPeerRejectEventMsg { Id = EventBuilder.GenerateEventId() };
ev1.Id.ShouldNotBeNullOrEmpty();
ev2.Id.ShouldNotBeNullOrEmpty();
ev1.Id.ShouldNotBe(ev2.Id);
}
// ========================================================================
// OcspChainValidationEvent
// Go reference: ocsp.go — OCSP chain validation advisory
// ========================================================================
/// <summary>
/// EventType constant must match the advisory type string.
/// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_chain_validation".
/// </summary>
[Fact]
public void OcspChainValidationEvent_HasCorrectEventType()
{
// Go reference: ocsp.go — chain validation advisory type constant.
OcspChainValidationEvent.EventType.ShouldBe("io.nats.server.advisory.v1.ocsp_chain_validation");
var ev = new OcspChainValidationEvent();
ev.Type.ShouldBe(OcspChainValidationEvent.EventType);
}
/// <summary>
/// All optional fields on OcspChainValidationEvent can be assigned.
/// Go reference: ocsp.go — chain validation advisory carries cert metadata.
/// </summary>
[Fact]
public void OcspChainValidationEvent_AllFieldsSettable()
{
// Go reference: ocsp.go — OCSP advisory fields: subject, issuer, serial, status.
var server = new EventServerInfo { Id = "srv1", Name = "test-server" };
var checkedAt = new DateTime(2026, 2, 25, 12, 0, 0, DateTimeKind.Utc);
var ev = new OcspChainValidationEvent
{
Server = server,
CertSubject = "CN=leaf.example.com",
CertIssuer = "CN=Intermediate CA",
SerialNumber = "0123456789abcdef",
OcspStatus = "good",
CheckedAt = checkedAt,
Error = null,
};
ev.Server.ShouldBeSameAs(server);
ev.CertSubject.ShouldBe("CN=leaf.example.com");
ev.CertIssuer.ShouldBe("CN=Intermediate CA");
ev.SerialNumber.ShouldBe("0123456789abcdef");
ev.OcspStatus.ShouldBe("good");
ev.CheckedAt.ShouldBe(checkedAt);
ev.Error.ShouldBeNull();
}
// ========================================================================
// OcspEventBuilder
// Go reference: ocsp.go — postOCSPPeerRejectEvent, chain validation advisory
// ========================================================================
/// <summary>
/// BuildPeerReject populates all required fields of OcspPeerRejectEventMsg.
/// Go reference: ocsp.go postOCSPPeerRejectEvent — kind and reason always set.
/// </summary>
[Fact]
public void BuildPeerReject_PopulatesAllFields()
{
// Go reference: ocsp.go — peer reject advisory carries kind + reason.
var ev = OcspEventBuilder.BuildPeerReject(
serverId: "srv-abc",
serverName: "my-server",
kind: "client",
reason: "certificate revoked");
ev.Id.ShouldNotBeNullOrEmpty();
ev.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
ev.Server.ShouldNotBeNull();
ev.Server.Id.ShouldBe("srv-abc");
ev.Server.Name.ShouldBe("my-server");
ev.Kind.ShouldBe("client");
ev.Reason.ShouldBe("certificate revoked");
ev.Time.ShouldNotBe(default);
}
/// <summary>
/// BuildChainValidation populates all fields of OcspChainValidationEvent.
/// Go reference: ocsp.go — chain validation advisory carries full cert metadata.
/// </summary>
[Fact]
public void BuildChainValidation_PopulatesAllFields()
{
// Go reference: ocsp.go — chain validation advisory fields.
var before = DateTime.UtcNow;
var ev = OcspEventBuilder.BuildChainValidation(
serverId: "srv-xyz",
serverName: "nats-1",
certSubject: "CN=client.example.com",
certIssuer: "CN=Root CA",
serialNumber: "deadbeef",
ocspStatus: "good",
error: null);
var after = DateTime.UtcNow;
ev.Id.ShouldNotBeNullOrEmpty();
ev.Type.ShouldBe(OcspChainValidationEvent.EventType);
ev.Server.ShouldNotBeNull();
ev.Server!.Id.ShouldBe("srv-xyz");
ev.Server.Name.ShouldBe("nats-1");
ev.CertSubject.ShouldBe("CN=client.example.com");
ev.CertIssuer.ShouldBe("CN=Root CA");
ev.SerialNumber.ShouldBe("deadbeef");
ev.OcspStatus.ShouldBe("good");
ev.CheckedAt.ShouldNotBeNull();
ev.CheckedAt!.Value.ShouldBeInRange(before, after);
ev.Error.ShouldBeNull();
}
// ========================================================================
// OcspStatus enum — ParseStatus
// Go reference: ocsp.go — ocspStatusGood, ocspStatusRevoked constants
// ========================================================================
/// <summary>
/// ParseStatus("good") returns OcspStatus.Good.
/// Go reference: ocsp.go — ocspStatusGood = "good".
/// </summary>
[Fact]
public void ParseStatus_Good()
{
// Go reference: ocsp.go — ocspStatusGood.
OcspEventBuilder.ParseStatus("good").ShouldBe(OcspStatus.Good);
OcspEventBuilder.ParseStatus("Good").ShouldBe(OcspStatus.Good);
OcspEventBuilder.ParseStatus("GOOD").ShouldBe(OcspStatus.Good);
}
/// <summary>
/// ParseStatus("revoked") returns OcspStatus.Revoked.
/// Go reference: ocsp.go — ocspStatusRevoked = "revoked".
/// </summary>
[Fact]
public void ParseStatus_Revoked()
{
// Go reference: ocsp.go — ocspStatusRevoked.
OcspEventBuilder.ParseStatus("revoked").ShouldBe(OcspStatus.Revoked);
OcspEventBuilder.ParseStatus("Revoked").ShouldBe(OcspStatus.Revoked);
OcspEventBuilder.ParseStatus("REVOKED").ShouldBe(OcspStatus.Revoked);
}
/// <summary>
/// ParseStatus with null or unrecognised string returns OcspStatus.Unknown.
/// Go reference: ocsp.go — unknown/unrecognised OCSP status treated as unknown.
/// </summary>
[Fact]
public void ParseStatus_Unknown()
{
// Go reference: ocsp.go — default case maps to unknown status.
OcspEventBuilder.ParseStatus(null).ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("unknown").ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("").ShouldBe(OcspStatus.Unknown);
OcspEventBuilder.ParseStatus("invalid-status").ShouldBe(OcspStatus.Unknown);
}
// ========================================================================
// JSON serialisation round-trip
// Go reference: ocsp.go — advisories are published as JSON payloads
// ========================================================================
/// <summary>
/// OcspPeerRejectEventMsg and OcspChainValidationEvent round-trip through JSON
/// preserving all set fields.
/// Go reference: ocsp.go — OCSP advisories serialised as JSON before publishing.
/// </summary>
[Fact]
public void OcspEvents_SerializeToJson()
{
// Go reference: ocsp.go — advisories are JSON-encoded for publishing.
var peerReject = OcspEventBuilder.BuildPeerReject(
serverId: "srv-1",
serverName: "node-a",
kind: "route",
reason: "OCSP status revoked");
var chainValidation = OcspEventBuilder.BuildChainValidation(
serverId: "srv-2",
serverName: "node-b",
certSubject: "CN=server.nats.io",
certIssuer: "CN=NATS CA",
serialNumber: "cafebabe",
ocspStatus: "revoked",
error: "certificate has been revoked");
// Serialise OcspPeerRejectEventMsg
var rejectJson = JsonSerializer.Serialize(peerReject);
rejectJson.ShouldContain("io.nats.server.advisory.v1.ocsp_peer_reject");
rejectJson.ShouldContain("route");
rejectJson.ShouldContain("OCSP status revoked");
var rejectDeserialized = JsonSerializer.Deserialize<OcspPeerRejectEventMsg>(rejectJson);
rejectDeserialized.ShouldNotBeNull();
rejectDeserialized!.Type.ShouldBe(OcspPeerRejectEventMsg.EventType);
rejectDeserialized.Kind.ShouldBe("route");
rejectDeserialized.Reason.ShouldBe("OCSP status revoked");
rejectDeserialized.Server.Id.ShouldBe("srv-1");
rejectDeserialized.Server.Name.ShouldBe("node-a");
// Serialise OcspChainValidationEvent
var chainJson = JsonSerializer.Serialize(chainValidation);
chainJson.ShouldContain("io.nats.server.advisory.v1.ocsp_chain_validation");
chainJson.ShouldContain("CN=server.nats.io");
chainJson.ShouldContain("revoked");
var chainDeserialized = JsonSerializer.Deserialize<OcspChainValidationEvent>(chainJson);
chainDeserialized.ShouldNotBeNull();
chainDeserialized!.Type.ShouldBe(OcspChainValidationEvent.EventType);
chainDeserialized.CertSubject.ShouldBe("CN=server.nats.io");
chainDeserialized.CertIssuer.ShouldBe("CN=NATS CA");
chainDeserialized.SerialNumber.ShouldBe("cafebabe");
chainDeserialized.OcspStatus.ShouldBe("revoked");
chainDeserialized.Error.ShouldBe("certificate has been revoked");
}
}