From a1351e3b7d7bd10f4009b410b3ad97a87a6a1097 Mon Sep 17 00:00:00 2001 From: Ademar Gonzalez Date: Thu, 24 Sep 2026 19:53:11 -0400 Subject: [PATCH] Return parsed EPP dates in UTC, as RFC 5730-5733 require RFC 5731 s2.4, 5732 s2.4 and 5733 s2.7 require every date-time to be UTC. The library parsed dates with DateTime.TryParse, which converts values with a Z to the machine's local time, so ExDate and the Nominet dates depended on where the code ran. Parse them through a shared EppDateTime helper that returns DateTimeKind.Utc, converts offsets to UTC and treats values without a zone designator (some of Nominet's examples omit the Z) as UTC. DomainRenew parsed curExpDate the same way before truncating it to a date, so a full exDate such as 2000-04-03T22:00:00.0Z became 2000-04-04 east of UTC and the registry would reject the renew. Also format the Smallregistry birthDate with the invariant culture. Bump to 1.6.0: callers now get UTC values instead of local time. Co-Authored-By: Claude Opus 5.5 --- EppLib.UnitTests/LocalTest.cs | 36 ++++++++++++++---- EppLib.UnitTests/NominetExtensionLocalTest.cs | 12 ++++-- EppLib/Entities/Domain/DomainRenew.cs | 2 +- EppLib/Entities/Domain/DomainRenewResponse.cs | 2 +- EppLib/EppDateTime.cs | 38 +++++++++++++++++++ EppLib/EppLib.csproj | 5 ++- .../ContactInfo/NominetContactInfoResponse.cs | 4 +- .../Notifications/AbuseNotification.cs | 2 +- .../DomainsSuspendedNotification.cs | 2 +- .../SmallregistryContactPPCreateExtension.cs | 3 +- 10 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 EppLib/EppDateTime.cs diff --git a/EppLib.UnitTests/LocalTest.cs b/EppLib.UnitTests/LocalTest.cs index be05c14..2ffefdf 100644 --- a/EppLib.UnitTests/LocalTest.cs +++ b/EppLib.UnitTests/LocalTest.cs @@ -17,13 +17,13 @@ public class LocalTest public TestContext TestContext { get; set; } - [TestInitialize] - public void TestSetup() - { - var testName = TestContext.TestName; - var method = new StackFrame().GetMethod().DeclaringType.GetMethod(testName); - var attributes = method.GetCustomAttributes(typeof(DeploymentItemAttribute), false); - DeploymentUtility.CopyDeploymentItems(attributes); + [TestInitialize] + public void TestSetup() + { + var testName = TestContext.TestName; + var method = new StackFrame().GetMethod().DeclaringType.GetMethod(testName); + var attributes = method.GetCustomAttributes(typeof(DeploymentItemAttribute), false); + DeploymentUtility.CopyDeploymentItems(attributes); } public LocalTest() @@ -1072,6 +1072,21 @@ public void TestDomainRenewCommand1() Assert.AreEqual(expected, command.ToXml().InnerXml); } + /// + /// Domain renew command given a full UTC date-time, as returned in exDate. The date must not shift with the local timezone. + /// + [TestMethod] + [TestCategory("LocalCommand")] + [DeploymentItem("TestData/DomainRenewCommand1.xml")] + public void TestDomainRenewCommandWithUtcDateTime() + { + string expected = File.ReadAllText("DomainRenewCommand1.xml"); + + var command = new DomainRenew("example.com", "2000-04-03T22:00:00.0Z", new DomainPeriod(5, "y")); + command.TransactionId = "ABC-12345"; + Assert.AreEqual(expected, command.ToXml().InnerXml); + } + /// /// Domain renew response, example RFC5731 /// @@ -1080,7 +1095,12 @@ public void TestDomainRenewCommand1() [DeploymentItem("TestData/DomainRenewResponse1.xml")] public void TestDomainRenewResponse1() { - Assert.Inconclusive("Not implemented"); + var response = new DomainRenewResponse(File.ReadAllBytes("DomainRenewResponse1.xml")); + + Assert.AreEqual("1000", response.Code); + Assert.IsNotNull(response.ExDate); + Assert.AreEqual(new DateTime(2005, 4, 3, 22, 0, 0, DateTimeKind.Utc), response.ExDate.Value); + Assert.AreEqual(DateTimeKind.Utc, response.ExDate.Value.Kind); } #endregion diff --git a/EppLib.UnitTests/NominetExtensionLocalTest.cs b/EppLib.UnitTests/NominetExtensionLocalTest.cs index 986f1de..b5be7dc 100644 --- a/EppLib.UnitTests/NominetExtensionLocalTest.cs +++ b/EppLib.UnitTests/NominetExtensionLocalTest.cs @@ -65,9 +65,11 @@ public void TestNominetContactInfoResponse1() Assert.AreEqual("invalid", response.DataQuality.Status); Assert.AreEqual("Incorrect Address", response.DataQuality.Reason); Assert.IsTrue(response.DataQuality.DateCommenced.HasValue); - Assert.AreEqual(new DateTime(2015,5,7,13,20,4).ToString(), response.DataQuality.DateCommenced.Value.ToString()); + Assert.AreEqual(new DateTime(2015, 5, 7, 13, 20, 4, DateTimeKind.Utc), response.DataQuality.DateCommenced.Value); + Assert.AreEqual(DateTimeKind.Utc, response.DataQuality.DateCommenced.Value.Kind); Assert.IsTrue(response.DataQuality.DateToSuspend.HasValue); - Assert.AreEqual(new DateTime(2015, 6, 6, 13, 20, 4).ToString(), response.DataQuality.DateToSuspend.Value.ToString()); + Assert.AreEqual(new DateTime(2015, 6, 6, 13, 20, 4, DateTimeKind.Utc), response.DataQuality.DateToSuspend.Value); + Assert.AreEqual(DateTimeKind.Utc, response.DataQuality.DateToSuspend.Value.Kind); Assert.IsTrue(response.DataQuality.LockApplied.HasValue); Assert.IsTrue(response.DataQuality.LockApplied.Value); Assert.IsNotNull(response.DataQuality.DomainList); @@ -314,7 +316,8 @@ public void TestAbuseNotification() Assert.AreEqual("www.youve.been.phished.co.uk", notification.HostName); Assert.AreEqual("http://www.youve.been.phished.co.uk/give/us/your/money.htm", notification.Url); Assert.IsNotNull(notification.Date); - Assert.AreEqual(new DateTime(2011, 3, 1, 11, 44, 1), notification.Date.Value); + Assert.AreEqual(new DateTime(2011, 3, 1, 11, 44, 1, DateTimeKind.Utc), notification.Date.Value); + Assert.AreEqual(DateTimeKind.Utc, notification.Date.Value.Kind); Assert.AreEqual("213.135.134.24", notification.Ip); Assert.AreEqual("ns0.crooked.dealings.net", notification.Nameserver); Assert.AreEqual("hostmaster@crooked.dealings.net", notification.DnsAdmin); @@ -350,7 +353,8 @@ public void TestDomainsSuspendedNotification() Assert.AreEqual("Data Quality", notification.SuspendedReason); Assert.IsNotNull(notification.CancelDate); - Assert.AreEqual(new DateTime(2009, 12, 12, 0, 0, 13, DateTimeKind.Utc), notification.CancelDate.Value.ToUniversalTime()); + Assert.AreEqual(new DateTime(2009, 12, 12, 0, 0, 13, DateTimeKind.Utc), notification.CancelDate.Value); + Assert.AreEqual(DateTimeKind.Utc, notification.CancelDate.Value.Kind); Assert.IsNotNull(notification.SuspendedDomains); Assert.AreEqual(2, notification.SuspendedDomains.Count); Assert.AreEqual("epp-example1.co.uk", notification.SuspendedDomains.First()); diff --git a/EppLib/Entities/Domain/DomainRenew.cs b/EppLib/Entities/Domain/DomainRenew.cs index 57e544e..a2ca87e 100755 --- a/EppLib/Entities/Domain/DomainRenew.cs +++ b/EppLib/Entities/Domain/DomainRenew.cs @@ -35,7 +35,7 @@ protected override XmlElement BuildCommandElement(XmlDocument doc, XmlElement co var domainRenew = BuildCommandElement(doc, "renew", commandRootElement); AddXmlElement(doc, domainRenew, "domain:name", DomainName, namespaceUri); - AddXmlElement(doc, domainRenew, "domain:curExpDate", DateTime.Parse(CurrentExpirationDate, CultureInfo.InvariantCulture).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), namespaceUri); + AddXmlElement(doc, domainRenew, "domain:curExpDate", EppDateTime.Parse(CurrentExpirationDate).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), namespaceUri); if (m_period != null) { diff --git a/EppLib/Entities/Domain/DomainRenewResponse.cs b/EppLib/Entities/Domain/DomainRenewResponse.cs index 09f0331..32b9852 100755 --- a/EppLib/Entities/Domain/DomainRenewResponse.cs +++ b/EppLib/Entities/Domain/DomainRenewResponse.cs @@ -41,7 +41,7 @@ protected override void ProcessDataNode(XmlDocument doc, XmlNamespaceManager nam if (exDateNode != null) { DateTime exDate; - if (DateTime.TryParse(exDateNode.InnerText, out exDate)) + if (EppDateTime.TryParse(exDateNode.InnerText, out exDate)) { this._exDate = exDate; } diff --git a/EppLib/EppDateTime.cs b/EppLib/EppDateTime.cs new file mode 100644 index 0000000..0bdc8c9 --- /dev/null +++ b/EppLib/EppDateTime.cs @@ -0,0 +1,38 @@ +// Copyright 2012 Code Maker Inc. (http://codemaker.net) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +using System; +using System.Globalization; + +namespace EppLib +{ + /// + /// Parses EPP date-time values. RFC 5730-5733 require every date-time to be UTC, so values + /// are returned as UTC (DateTimeKind.Utc). Values with an offset are converted to UTC, and + /// values without a zone designator, which some registries send, are taken to be UTC. + /// + internal static class EppDateTime + { + private const DateTimeStyles Styles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; + + public static bool TryParse(string value, out DateTime result) + { + return DateTime.TryParse(value, CultureInfo.InvariantCulture, Styles, out result); + } + + public static DateTime Parse(string value) + { + return DateTime.Parse(value, CultureInfo.InvariantCulture, Styles); + } + } +} diff --git a/EppLib/EppLib.csproj b/EppLib/EppLib.csproj index e4f40cb..186de71 100644 --- a/EppLib/EppLib.csproj +++ b/EppLib/EppLib.csproj @@ -4,7 +4,7 @@ netstandard2.0;net10.0 EppLib EppLib - 1.5.0 + 1.6.0 Code Maker Inc. and Contributors EppLib.NET provides a library that makes easy for registrars to interact with registries implementing the Extensible Provisioning Protocol (EPP). Includes extensions for CIRA (.CA), Nominet (.UK), IIS (.SE), Verisign Namestore, LaunchPhase and SecDNS. epp;registrar;registry;domain;rfc5730 @@ -13,7 +13,7 @@ https://github.com/CodeMakerInc/EppLib.NET.git git README.md - Breaking: TcpTransport now validates the registry's server certificate (trusted chain and matching host name) instead of accepting any certificate. For test environments with self-signed certificates, set TcpTransport.ServerCertificateValidationCallback. + Breaking: parsed EPP dates (DomainRenewResponse.ExDate and the Nominet dates) are now returned in UTC (DateTimeKind.Utc) as RFC 5730-5733 require, instead of being converted to the machine's local time. Values without a zone designator are taken as UTC. Fixes DomainRenew sending the wrong curExpDate east of UTC when given a full date-time, and a culture-dependent Smallregistry birthDate. true false false @@ -179,6 +179,7 @@ + diff --git a/EppLib/Extensions/Nominet/ContactInfo/NominetContactInfoResponse.cs b/EppLib/Extensions/Nominet/ContactInfo/NominetContactInfoResponse.cs index b85752a..b175a48 100644 --- a/EppLib/Extensions/Nominet/ContactInfo/NominetContactInfoResponse.cs +++ b/EppLib/Extensions/Nominet/ContactInfo/NominetContactInfoResponse.cs @@ -94,7 +94,7 @@ protected override void ProcessExtensionNode(XmlDocument doc, XmlNamespaceManage if (dateCommenced != null) { DateTime date; - if (DateTime.TryParse(dateCommenced.InnerText, out date)) + if (EppDateTime.TryParse(dateCommenced.InnerText, out date)) { _nomDataQuality.DateCommenced = date; } @@ -103,7 +103,7 @@ protected override void ProcessExtensionNode(XmlDocument doc, XmlNamespaceManage if (dateToSuspend != null) { DateTime date; - if (DateTime.TryParse(dateToSuspend.InnerText, out date)) + if (EppDateTime.TryParse(dateToSuspend.InnerText, out date)) { _nomDataQuality.DateToSuspend = date; } diff --git a/EppLib/Extensions/Nominet/Notifications/AbuseNotification.cs b/EppLib/Extensions/Nominet/Notifications/AbuseNotification.cs index 71c115d..da3a378 100644 --- a/EppLib/Extensions/Nominet/Notifications/AbuseNotification.cs +++ b/EppLib/Extensions/Nominet/Notifications/AbuseNotification.cs @@ -57,7 +57,7 @@ protected override void ProcessDataNode(XmlDocument doc, XmlNamespaceManager nam if (dateNode != null) { DateTime parsedDate; - if (DateTime.TryParse(dateNode.InnerText, out parsedDate)) + if (EppDateTime.TryParse(dateNode.InnerText, out parsedDate)) { Date = parsedDate; } diff --git a/EppLib/Extensions/Nominet/Notifications/DomainsSuspendedNotification.cs b/EppLib/Extensions/Nominet/Notifications/DomainsSuspendedNotification.cs index 1b04779..f425109 100644 --- a/EppLib/Extensions/Nominet/Notifications/DomainsSuspendedNotification.cs +++ b/EppLib/Extensions/Nominet/Notifications/DomainsSuspendedNotification.cs @@ -32,7 +32,7 @@ protected override void ProcessDataNode(XmlDocument doc, XmlNamespaceManager nam if (cancelDateNode != null) { DateTime parsedDate; - if (DateTime.TryParse(cancelDateNode.InnerText, out parsedDate)) + if (EppDateTime.TryParse(cancelDateNode.InnerText, out parsedDate)) { CancelDate = parsedDate; } diff --git a/EppLib/Extensions/Smallregistry/ContactCreate/SmallregistryContactPPCreateExtension.cs b/EppLib/Extensions/Smallregistry/ContactCreate/SmallregistryContactPPCreateExtension.cs index 990090e..1a225a6 100644 --- a/EppLib/Extensions/Smallregistry/ContactCreate/SmallregistryContactPPCreateExtension.cs +++ b/EppLib/Extensions/Smallregistry/ContactCreate/SmallregistryContactPPCreateExtension.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Xml; namespace EppLib.Extensions.Smallregistry @@ -19,7 +20,7 @@ public override XmlNode ToXml(XmlDocument doc) if (BirthDate != null) { - AddXmlElement(doc, person, "birthDate", BirthDate.ToString("yyyy-MM-dd")); + AddXmlElement(doc, person, "birthDate", BirthDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } if (BirthPlace != null)