diff --git a/src/NATS.Server/Events/EventJsonContext.cs b/src/NATS.Server/Events/EventJsonContext.cs index d601bff..7998a16 100644 --- a/src/NATS.Server/Events/EventJsonContext.cs +++ b/src/NATS.Server/Events/EventJsonContext.cs @@ -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; diff --git a/src/NATS.Server/Events/EventSubjects.cs b/src/NATS.Server/Events/EventSubjects.cs index 81401c6..7b6d35e 100644 --- a/src/NATS.Server/Events/EventSubjects.cs +++ b/src/NATS.Server/Events/EventSubjects.cs @@ -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}"; diff --git a/src/NATS.Server/Events/EventTypes.cs b/src/NATS.Server/Events/EventTypes.cs index d5a1f7b..5106123 100644 --- a/src/NATS.Server/Events/EventTypes.cs +++ b/src/NATS.Server/Events/EventTypes.cs @@ -540,6 +540,119 @@ public sealed class OcspPeerRejectEventMsg public string Reason { get; set; } = string.Empty; } +/// +/// OCSP chain validation advisory, published when a certificate's OCSP status +/// is checked during TLS handshake. +/// Go reference: ocsp.go — OCSP peer verification advisory. +/// +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; } + + /// OCSP status string: "good", "revoked", or "unknown". + [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; } +} + +/// +/// OCSP status values matching the Go OCSP response status strings. +/// Go reference: ocsp.go — ocspStatusGood, ocspStatusRevoked, ocspStatusUnknown. +/// +public enum OcspStatus +{ + Unknown, + Good, + Revoked, +} + +/// +/// Factory helpers for constructing OCSP advisory event messages. +/// Go reference: ocsp.go — OCSP peer reject and chain validation advisory publishing. +/// +public static class OcspEventBuilder +{ + /// + /// Build an OcspPeerRejectEventMsg for a rejected peer certificate. + /// Go reference: ocsp.go — postOCSPPeerRejectEvent. + /// + 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, + }; + + /// + /// Build an OcspChainValidationEvent for a certificate OCSP check. + /// Go reference: ocsp.go — OCSP chain validation advisory. + /// + 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, + }; + + /// + /// Parse an OCSP status string into the enum. + /// Go reference: ocsp.go — ocspStatusGood / ocspStatusRevoked string constants. + /// + public static OcspStatus ParseStatus(string? status) => + status?.ToLowerInvariant() switch + { + "good" => OcspStatus.Good, + "revoked" => OcspStatus.Revoked, + _ => OcspStatus.Unknown, + }; +} + /// /// Remote server shutdown advisory. /// Go reference: events.go — remote server lifecycle. diff --git a/tests/NATS.Server.Tests/Events/OcspEventTests.cs b/tests/NATS.Server.Tests/Events/OcspEventTests.cs new file mode 100644 index 0000000..43f07ec --- /dev/null +++ b/tests/NATS.Server.Tests/Events/OcspEventTests.cs @@ -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; + +/// +/// Tests for , , +/// , and the enum. +/// Go reference: ocsp.go — OCSP peer verification advisory events (Gap 10.10). +/// +public class OcspEventTests +{ + // ======================================================================== + // OcspPeerRejectEventMsg + // Go reference: ocsp.go postOCSPPeerRejectEvent + // ======================================================================== + + /// + /// EventType constant must match the Go advisory type string. + /// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_peer_reject". + /// + [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); + } + + /// + /// Each OcspPeerRejectEventMsg gets its own unique non-empty Id. + /// Go reference: ocsp.go — nuid.Next() generates a unique event ID per advisory. + /// + [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 + // ======================================================================== + + /// + /// EventType constant must match the advisory type string. + /// Go reference: ocsp.go — "io.nats.server.advisory.v1.ocsp_chain_validation". + /// + [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); + } + + /// + /// All optional fields on OcspChainValidationEvent can be assigned. + /// Go reference: ocsp.go — chain validation advisory carries cert metadata. + /// + [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 + // ======================================================================== + + /// + /// BuildPeerReject populates all required fields of OcspPeerRejectEventMsg. + /// Go reference: ocsp.go postOCSPPeerRejectEvent — kind and reason always set. + /// + [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); + } + + /// + /// BuildChainValidation populates all fields of OcspChainValidationEvent. + /// Go reference: ocsp.go — chain validation advisory carries full cert metadata. + /// + [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 + // ======================================================================== + + /// + /// ParseStatus("good") returns OcspStatus.Good. + /// Go reference: ocsp.go — ocspStatusGood = "good". + /// + [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); + } + + /// + /// ParseStatus("revoked") returns OcspStatus.Revoked. + /// Go reference: ocsp.go — ocspStatusRevoked = "revoked". + /// + [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); + } + + /// + /// ParseStatus with null or unrecognised string returns OcspStatus.Unknown. + /// Go reference: ocsp.go — unknown/unrecognised OCSP status treated as unknown. + /// + [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 + // ======================================================================== + + /// + /// OcspPeerRejectEventMsg and OcspChainValidationEvent round-trip through JSON + /// preserving all set fields. + /// Go reference: ocsp.go — OCSP advisories serialised as JSON before publishing. + /// + [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(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(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"); + } +}