diff --git a/docs/oracle-dns-protocol.md b/docs/oracle-dns-protocol.md new file mode 100644 index 000000000..3df8974f8 --- /dev/null +++ b/docs/oracle-dns-protocol.md @@ -0,0 +1,129 @@ +# Oracle DNS Protocol + +The Oracle plugin resolves RFC 4501 `dns:` URIs through a DNS-over-HTTPS (DoH) gateway. This lets oracle nodes read authoritative DNS data (TXT for DKIM/SPF/DIDs, CERT/TLSA, etc.) without sending plaintext DNS queries. + +> **When should I use it?** +> Whenever you need DNS data on-chain and want the request to stay encrypted end-to-end. + +## Enable and configure + +1. Install or build the `OracleService` plugin and copy `OracleService.json` next to the plugin binary. +2. Add the `Dns` section (defaults shown): + +```jsonc +{ + "PluginConfiguration": { + // ... + "Dns": { + "EndPoint": "https://cloudflare-dns.com/dns-query", + "Timeout": 5000 + } + } +} +``` + +- `EndPoint` must point to a DoH resolver that understands the [application/dns-json](https://developers.cloudflare.com/api/operations/dns-over-https) format. +- `Timeout` is the maximum milliseconds the oracle will wait for a DoH response before returning `OracleResponseCode.Timeout`. + +> You can run your own DoH gateway and point the oracle to it if you need custom trust anchors or strict egress controls. + +## RFC 4501 URI format + +``` +dns:[//authority/]domain[?CLASS=class;TYPE=type][;FORMAT=x509] +``` + +- `domain` is the DNS owner name (relative or absolute). Percent-encoding and escaped dots (`%5c.`) follow RFC 4501 rules. +- `domain` must not include additional path segments; only the owner name belongs here. +- `authority` is the optional DNS server hint from RFC 4501. The oracle still uses the DoH resolver configured in `OracleService.json`. +- `CLASS` is optional and case-insensitive. Only `IN` (`1`) is supported; other classes are rejected. +- `TYPE` is optional and case-insensitive. Use mnemonics (`TXT`, `TLSA`, `CERT`, `A`, `AAAA`, …) or numeric values. Defaults to `A` per RFC 4501. +- `FORMAT` is an oracle extension; use `format=x509` (or `cert`) to parse TXT/CERT payloads into the `Certificate` field. +- `name` is an oracle extension; if present, it overrides `domain` entirely (useful for percent-encoding complex owner names). + +Query parameters can be separated by `;` (RFC style) or `&`. + +Examples: + +- `dns:1alhai._domainkey.icloud.com?TYPE=TXT` — DKIM TXT record. +- `dns:simon.example.org?TYPE=CERT;FORMAT=x509` — extract the X.509 payload into `Certificate`. +- `dns://192.168.1.1/ftp.example.org?TYPE=A` — RFC-compliant authority form (authority is ignored; the configured DoH endpoint is used). +- `dns:ignored?name=weird%5c.label.example&type=TXT` — uses the `name` override (decoded to `weird\.label.example`). + +## Response schema + +Successful queries return UTF-8 JSON. Attributes correspond to the `ResultEnvelope` produced by the oracle: + +```jsonc +{ + "Name": "1alhai._domainkey.icloud.com", + "Type": "TXT", + "Answers": [ + { + "Name": "1alhai._domainkey.icloud.com", + "Type": "TXT", + "Ttl": 299, + "Data": "\"k=rsa; p=...IDAQAB\"" + } + ], + "Certificate": { + "Subject": "CN=example.com", + "Issuer": "CN=Example Root", + "Thumbprint": "ABCD1234...", + "NotBefore": "2024-01-16T00:00:00Z", + "NotAfter": "2025-01-16T00:00:00Z", + "Der": "MIIC...", + "PublicKey": { + "Algorithm": "RSA", + "Encoded": "MIIBIjANBg...", + "Modulus": "B968DE...", + "Exponent": "010001" + } + } +} +``` + +- `Answers` mirrors the DoH response but normalizes record types and names. +- `Certificate` is present only when `TYPE=CERT` or `FORMAT=x509`. `Der` is the base64-encoded certificate, while `PublicKey` provides both the encoded SubjectPublicKeyInfo (`Encoded`) and algorithm-specific fields (`Modulus`/`Exponent` for RSA, `Curve`/`X`/`Y` for EC). +- For RSA keys the modulus/exponent strings are big-endian hex. For EC keys the X/Y coordinates are hex-encoded affine coordinates on the reported `Curve`. +- If the DoH server responds with NXDOMAIN, the oracle returns `OracleResponseCode.NotFound`. +- Responses exceeding `OracleResponse.MaxResultSize` yield `OracleResponseCode.ResponseTooLarge`. + +## Contract usage example + +```csharp +public static void RequestAppleDkim() +{ + const string url = "dns:1alhai._domainkey.icloud.com?TYPE=TXT"; + Oracle.Request(url, "", nameof(OnOracleCallback), Runtime.CallingScriptHash, 5_00000000); +} + +public static void OnOracleCallback(string url, byte[] userData, int code, byte[] result) +{ + if (code != (int)OracleResponseCode.Success) throw new Exception("Oracle query failed"); + + var envelope = (Neo.SmartContract.Framework.Services.Neo.Json.JsonObject)StdLib.JsonDeserialize(result); + var answers = (Neo.SmartContract.Framework.Services.Neo.Json.JsonArray)envelope["Answers"]; + var txt = (Neo.SmartContract.Framework.Services.Neo.Json.JsonObject)answers[0]; + Storage.Put(Storage.CurrentContext, "dkim", txt["Data"].AsString()); +} +``` + +Tips: + +1. Always set `TYPE` when you need anything other than an A record. +2. Budget enough `gasForResponse` to cover JSON payload size (TXT records are often kilobytes). +3. Validate TTL or fingerprint data before trusting it. +4. Combine oracle DNS data with existing filters (e.g., `Helper.JsonPath`/`OracleService.Filter`) if you only need a slice of the result. + +## Manual testing + +Use the same resolver the oracle will contact to inspect responses: + +```bash +curl -s \ + -H 'accept: application/dns-json' \ + 'https://cloudflare-dns.com/dns-query?name=1alhai._domainkey.icloud.com&type=TXT' +``` + +Compare the JSON payload with the data returned by your contract callback to ensure parity. diff --git a/plugins/OracleService/OracleService.cs b/plugins/OracleService/OracleService.cs index fe24d099c..8f4215ead 100644 --- a/plugins/OracleService/OracleService.cs +++ b/plugins/OracleService/OracleService.cs @@ -147,6 +147,7 @@ public Task Start(Wallet wallet) this.wallet = wallet; protocols["https"] = new OracleHttpsProtocol(); + protocols["dns"] = new OracleDnsProtocol(); protocols["neofs"] = new OracleNeoFSProtocol(wallet, oracles); status = OracleStatus.Running; timer = new Timer(OnTimer, null, RefreshIntervalMilliSeconds, Timeout.Infinite); diff --git a/plugins/OracleService/OracleService.json b/plugins/OracleService/OracleService.json index 49bf1153b..c9db00ef7 100644 --- a/plugins/OracleService/OracleService.json +++ b/plugins/OracleService/OracleService.json @@ -14,6 +14,10 @@ "EndPoint": "http://127.0.0.1:8080", "Timeout": 15000 }, + "Dns": { + "EndPoint": "https://cloudflare-dns.com/dns-query", + "Timeout": 5000 + }, "AutoStart": false }, "Dependency": [ diff --git a/plugins/OracleService/OracleSettings.cs b/plugins/OracleService/OracleSettings.cs index f0e518898..5e1a22e89 100644 --- a/plugins/OracleService/OracleSettings.cs +++ b/plugins/OracleService/OracleSettings.cs @@ -36,6 +36,19 @@ public NeoFSSettings(IConfigurationSection section) } } +class DnsSettings +{ + public Uri EndPoint { get; } + public TimeSpan Timeout { get; } + + public DnsSettings(IConfigurationSection section) + { + string endpoint = section.GetValue("EndPoint", "https://cloudflare-dns.com/dns-query"); + EndPoint = new Uri(endpoint, UriKind.Absolute); + Timeout = TimeSpan.FromMilliseconds(section.GetValue("Timeout", 5000)); + } +} + class OracleSettings : IPluginSettings { public uint Network { get; } @@ -46,6 +59,7 @@ class OracleSettings : IPluginSettings public string[] AllowedContentTypes { get; } public HttpsSettings Https { get; } public NeoFSSettings NeoFS { get; } + public DnsSettings Dns { get; } public bool AutoStart { get; } public static OracleSettings Default { get; private set; } @@ -65,6 +79,7 @@ private OracleSettings(IConfigurationSection section) AllowedContentTypes = AllowedContentTypes.Concat("application/json").ToArray(); Https = new HttpsSettings(section.GetSection("Https")); NeoFS = new NeoFSSettings(section.GetSection("NeoFS")); + Dns = new DnsSettings(section.GetSection("Dns")); AutoStart = section.GetValue("AutoStart", false); } diff --git a/plugins/OracleService/Protocols/OracleDnsProtocol.cs b/plugins/OracleService/Protocols/OracleDnsProtocol.cs new file mode 100644 index 000000000..a0ddbec14 --- /dev/null +++ b/plugins/OracleService/Protocols/OracleDnsProtocol.cs @@ -0,0 +1,503 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// OracleDnsProtocol.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Neo.Network.P2P.Payloads; +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +namespace Neo.Plugins.OracleService.Protocols; + +class OracleDnsProtocol : IOracleProtocol +{ + private sealed class DohAnswer + { + public string Name { get; set; } + public int Type { get; set; } + public uint Ttl { get; set; } + public string Data { get; set; } + } + + private sealed class DohResponse + { + public int Status { get; set; } + public DohAnswer[] Answer { get; set; } + } + + private sealed class ResultAnswer + { + public string Name { get; set; } + public string Type { get; set; } + public uint Ttl { get; set; } + public string Data { get; set; } + } + + private sealed class CertificateResult + { + public string Subject { get; set; } + public string Issuer { get; set; } + public string Thumbprint { get; set; } + public DateTime NotBefore { get; set; } + public DateTime NotAfter { get; set; } + public string Der { get; set; } + public CertificatePublicKey PublicKey { get; set; } + } + + private sealed class CertificatePublicKey + { + public string Algorithm { get; set; } + public string Encoded { get; set; } + public string Modulus { get; set; } + public string Exponent { get; set; } + public string Curve { get; set; } + public string X { get; set; } + public string Y { get; set; } + } + + private sealed class ResultEnvelope + { + public string Name { get; set; } + public string Type { get; set; } + public ResultAnswer[] Answers { get; set; } + public CertificateResult Certificate { get; set; } + } + + private static readonly IReadOnlyDictionary RecordTypeLookup = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["A"] = 1, + ["NS"] = 2, + ["CNAME"] = 5, + ["SOA"] = 6, + ["MX"] = 15, + ["TXT"] = 16, + ["AAAA"] = 28, + ["SRV"] = 33, + ["CERT"] = 37, + ["DNSKEY"] = 48, + ["TLSA"] = 52, + }; + + private static readonly IReadOnlyDictionary ReverseRecordTypeLookup = + RecordTypeLookup.ToDictionary(p => p.Value, p => p.Key); + + private readonly HttpClient client; + private readonly JsonSerializerOptions serializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + private readonly JsonSerializerOptions resultSerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private readonly object syncRoot = new(); + private bool configured; + private Uri endpoint; + + public OracleDnsProtocol(HttpMessageHandler handler = null) + { + client = handler is null ? new HttpClient() : new HttpClient(handler); + CustomAttributeData attribute = Assembly.GetExecutingAssembly().CustomAttributes.First(p => p.AttributeType == typeof(AssemblyInformationalVersionAttribute)); + string version = (string)attribute.ConstructorArguments[0].Value; + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("NeoOracleService", version)); + } + + public void Configure() + { + EnsureConfigured(force: true); + } + + public void Dispose() + { + client.Dispose(); + } + + public async Task<(OracleResponseCode, string)> ProcessAsync(Uri uri, CancellationToken cancellation) + { + EnsureConfigured(); + + string queryName; + NameValueCollection query; + try + { + query = ParseQueryString(uri.Query); + queryName = BuildQueryName(uri, query); + ValidateClass(query); + } + catch (Exception ex) + { + return (OracleResponseCode.Error, ex.Message); + } + + int recordType; + string recordTypeLabel; + try + { + recordType = ParseRecordType(query); + recordTypeLabel = GetRecordTypeLabel(recordType); + } + catch (Exception ex) + { + return (OracleResponseCode.Error, ex.Message); + } + + bool wantsCertificate = recordType == RecordTypeLookup["CERT"] || ShouldExtractCertificate(query); + Utility.Log(nameof(OracleDnsProtocol), LogLevel.Debug, $"Request: {queryName} ({recordTypeLabel})"); + + DohResponse dohResponse; + try + { + dohResponse = await ResolveAsync(queryName, recordType, cancellation); + } + catch (TaskCanceledException) + { + return (OracleResponseCode.Timeout, null); + } + catch (Exception ex) + { + return (OracleResponseCode.Error, ex.Message); + } + + if (dohResponse is null) + return (OracleResponseCode.Error, "Invalid DNS response."); + + if (dohResponse.Status == 3) + return (OracleResponseCode.NotFound, null); + + if (dohResponse.Status != 0) + return (OracleResponseCode.Error, $"DNS error {dohResponse.Status}"); + + if (dohResponse.Answer is null || dohResponse.Answer.Length == 0) + return (OracleResponseCode.NotFound, null); + + ResultAnswer[] answers = dohResponse.Answer + .Select(a => new ResultAnswer + { + Name = a.Name?.TrimEnd('.'), + Type = GetRecordTypeLabel(a.Type), + Ttl = a.Ttl, + Data = a.Data + }) + .ToArray(); + + CertificateResult certificate = null; + bool certificateOversized = false; + if (wantsCertificate && TryBuildCertificate(dohResponse.Answer, out certificate, out certificateOversized)) + Utility.Log(nameof(OracleDnsProtocol), LogLevel.Debug, $"Certificate extracted for {queryName}"); + if (certificateOversized) + return (OracleResponseCode.ResponseTooLarge, null); + + ResultEnvelope envelope = new() + { + Name = queryName, + Type = recordTypeLabel, + Answers = answers, + Certificate = certificate + }; + + string payload = JsonSerializer.Serialize(envelope, resultSerializerOptions); + if (Encoding.UTF8.GetByteCount(payload) > OracleResponse.MaxResultSize) + return (OracleResponseCode.ResponseTooLarge, null); + + return (OracleResponseCode.Success, payload); + } + + private async Task ResolveAsync(string name, int type, CancellationToken cancellation) + { + Uri requestUri = BuildRequestUri(name, type); + using HttpResponseMessage response = await client.GetAsync(requestUri, cancellation); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"DoH endpoint returned {(int)response.StatusCode} ({response.StatusCode})"); + await using var stream = await response.Content.ReadAsStreamAsync(cancellation); + return await JsonSerializer.DeserializeAsync(stream, serializerOptions, cancellation); + } + + private Uri BuildRequestUri(string name, int type) + { + UriBuilder builder = new(endpoint); + NameValueCollection existingQuery = HttpUtility.ParseQueryString(builder.Query ?? string.Empty); + existingQuery["name"] = name.TrimEnd('.'); + existingQuery["type"] = type.ToString(CultureInfo.InvariantCulture); + builder.Query = existingQuery.ToString(); + return builder.Uri; + } + + private void EnsureConfigured(bool force = false) + { + if (configured && !force) + return; + lock (syncRoot) + { + if (configured && !force) + return; + var dnsSettings = OracleSettings.Default?.Dns ?? throw new InvalidOperationException("DNS settings are not loaded."); + endpoint = dnsSettings.EndPoint; + client.Timeout = dnsSettings.Timeout; + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.ParseAdd("application/dns-json"); + configured = true; + } + } + + internal static string BuildQueryName(Uri uri, NameValueCollection queryParameters = null) + { + queryParameters ??= ParseQueryString(uri.Query); + string overriddenName = NormalizeLabel(GetQueryValue(queryParameters, "name")); + if (!string.IsNullOrEmpty(overriddenName)) + return overriddenName.TrimEnd('.'); + + string dnsName = NormalizeDnsName(uri.GetComponents(UriComponents.Path, UriFormat.Unescaped)); + if (string.IsNullOrEmpty(dnsName)) + throw new FormatException("dns: URI must include a dnsname."); + + return dnsName; + } + + private static NameValueCollection ParseQueryString(string query) + { + string normalized = string.IsNullOrEmpty(query) + ? string.Empty + : query.TrimStart('?').Replace(';', '&'); + return HttpUtility.ParseQueryString(normalized); + } + + private static string GetQueryValue(NameValueCollection query, string key) + { + if (query is null) + return null; + foreach (string existing in query) + { + if (existing is null) + continue; + if (existing.Equals(key, StringComparison.OrdinalIgnoreCase)) + return query[existing]; + } + return null; + } + + private static void ValidateClass(NameValueCollection query) + { + string classRaw = GetQueryValue(query, "class"); + if (string.IsNullOrWhiteSpace(classRaw)) + return; + classRaw = classRaw.Trim(); + if (classRaw.Equals("IN", StringComparison.OrdinalIgnoreCase) || classRaw == "1") + return; + throw new FormatException($"Unsupported DNS class '{classRaw}', only IN is supported."); + } + + private static string NormalizeDnsName(string value) + { + string normalized = NormalizeLabel(value?.Trim('/')); + if (string.IsNullOrEmpty(normalized)) + throw new FormatException("dns: URI must include a dnsname."); + if (normalized.Contains('/')) + throw new FormatException("dnsname must not contain path segments."); + return normalized; + } + + private static string NormalizeLabel(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + return Uri.UnescapeDataString(value).Trim().Trim('.'); + } + + private static int ParseRecordType(NameValueCollection query) + { + string typeRaw = GetQueryValue(query, "type"); + if (string.IsNullOrWhiteSpace(typeRaw)) + return RecordTypeLookup["A"]; + typeRaw = typeRaw.Trim(); + if (int.TryParse(typeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int numeric)) + return numeric; + if (RecordTypeLookup.TryGetValue(typeRaw, out int mapped)) + return mapped; + throw new FormatException($"Unsupported DNS record type '{typeRaw}'"); + } + + private static string GetRecordTypeLabel(int type) + { + if (ReverseRecordTypeLookup.TryGetValue(type, out string label)) + return label; + return type.ToString(CultureInfo.InvariantCulture); + } + + private static bool ShouldExtractCertificate(NameValueCollection query) + { + string format = GetQueryValue(query, "format"); + if (string.IsNullOrWhiteSpace(format)) + return false; + return format.Equals("x509", StringComparison.OrdinalIgnoreCase) + || format.Equals("cert", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryBuildCertificate(IEnumerable answers, out CertificateResult certificate, out bool oversized) + { + certificate = null; + oversized = false; + foreach (var answer in answers) + { + if (!CanContainCertificate(answer)) + continue; + + if (!TryExtractCertificateBytes(answer.Data, out byte[] raw, out bool candidateOversized)) + { + oversized |= candidateOversized; + continue; + } + + try + { + using X509Certificate2 cert = X509CertificateLoader.LoadCertificate(raw); + certificate = new CertificateResult + { + Subject = cert.Subject, + Issuer = cert.Issuer, + Thumbprint = cert.Thumbprint, + NotBefore = cert.NotBefore, + NotAfter = cert.NotAfter, + Der = Convert.ToBase64String(cert.Export(X509ContentType.Cert)), + PublicKey = BuildPublicKey(cert) + }; + return true; + } + catch + { + oversized |= candidateOversized; + // Skip invalid certificate payloads + } + } + return false; + } + + private static CertificatePublicKey BuildPublicKey(X509Certificate2 cert) + { + CertificatePublicKey key = new() + { + Algorithm = cert.PublicKey.Oid?.FriendlyName ?? cert.PublicKey.Oid?.Value, + Encoded = Convert.ToBase64String(cert.GetPublicKey()) + }; + + try + { + using RSA rsa = cert.GetRSAPublicKey(); + if (rsa is not null) + { + RSAParameters parameters = rsa.ExportParameters(false); + key.Modulus = Convert.ToHexString(parameters.Modulus); + key.Exponent = Convert.ToHexString(parameters.Exponent); + return key; + } + } + catch + { + // ignore and fall through + } + + try + { + using ECDsa ecdsa = cert.GetECDsaPublicKey(); + if (ecdsa is not null) + { + ECParameters parameters = ecdsa.ExportParameters(false); + key.Curve = parameters.Curve.Oid?.FriendlyName ?? parameters.Curve.Oid?.Value; + key.X = Convert.ToHexString(parameters.Q.X); + key.Y = Convert.ToHexString(parameters.Q.Y); + return key; + } + } + catch + { + } + + return key; + } + + private static bool CanContainCertificate(DohAnswer answer) + { + if (answer is null || string.IsNullOrEmpty(answer.Data)) + return false; + return answer.Type == RecordTypeLookup["CERT"] || answer.Type == RecordTypeLookup["TXT"]; + } + + private static bool TryExtractCertificateBytes(string payload, out byte[] raw, out bool oversized) + { + raw = null; + oversized = false; + if (string.IsNullOrWhiteSpace(payload)) + return false; + + string cleaned = payload.Trim(); + int lastSpace = cleaned.LastIndexOf(' '); + if (lastSpace > 0) + { + string tail = cleaned[(lastSpace + 1)..]; + if (TryDecodeBase64Safe(tail, out raw, out oversized)) + return true; + if (oversized) + return false; + } + + string normalized = cleaned.Replace("\"", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + return TryDecodeBase64Safe(normalized, out raw, out oversized); + } + + private static bool TryDecodeBase64Safe(string input, out byte[] data, out bool oversized) + { + data = null; + oversized = false; + if (string.IsNullOrWhiteSpace(input)) + return false; + if (ExceedsMaxCertificateSize(input)) + { + oversized = true; + return false; + } + try + { + data = Convert.FromBase64String(input); + if (data.Length > OracleResponse.MaxResultSize) + { + oversized = true; + data = null; + return false; + } + return data.Length > 0; + } + catch + { + return false; + } + } + + private static bool ExceedsMaxCertificateSize(string base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return false; + long length = base64.Length; + long estimatedDecoded = 3L * ((length + 3) / 4); // ceil(length/4) * 3 + return estimatedDecoded > OracleResponse.MaxResultSize; + } +} diff --git a/tests/Neo.Plugins.OracleService.Tests/UT_OracleDnsProtocol.cs b/tests/Neo.Plugins.OracleService.Tests/UT_OracleDnsProtocol.cs new file mode 100644 index 000000000..7590808cd --- /dev/null +++ b/tests/Neo.Plugins.OracleService.Tests/UT_OracleDnsProtocol.cs @@ -0,0 +1,363 @@ +// Copyright (C) 2015-2025 The Neo Project. +// +// UT_OracleDnsProtocol.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using Microsoft.Extensions.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Neo.Network.P2P.Payloads; +using Neo.Plugins.OracleService.Protocols; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Neo.Plugins.OracleService.Tests; + +[TestClass] +public class UT_OracleDnsProtocol +{ + [TestInitialize] + public void Setup() + { + LoadSettings(); + } + + [TestMethod] + public void BuildQueryName_ParsesDnsUri() + { + var uri = new Uri("dns:simon.example.org?TYPE=TXT"); + string name = OracleDnsProtocol.BuildQueryName(uri); + Assert.AreEqual("simon.example.org", name); + } + + [TestMethod] + public void BuildQueryName_RespectsAuthoritySyntax() + { + var uri = new Uri("dns://resolver.example/ftp.example.org?TYPE=TXT"); + string name = OracleDnsProtocol.BuildQueryName(uri); + Assert.AreEqual("ftp.example.org", name); + } + + [TestMethod] + public void BuildQueryName_ThrowsWithoutDnsName() + { + var uri = new Uri("dns://resolver.example/"); + try + { + OracleDnsProtocol.BuildQueryName(uri); + Assert.Fail("Expected FormatException for missing dnsname."); + } + catch (FormatException) + { + } + } + + [TestMethod] + public void BuildQueryName_UsesNameOverride() + { + var uri = new Uri("dns://resolver.example/ignored?name=override.example.com"); + string name = OracleDnsProtocol.BuildQueryName(uri); + Assert.AreEqual("override.example.com", name); + } + + [TestMethod] + public void BuildQueryName_RejectsPathSegments() + { + var uri = new Uri("dns:example.com/extra"); + try + { + OracleDnsProtocol.BuildQueryName(uri); + Assert.Fail("Expected FormatException for path segments."); + } + catch (FormatException) + { + } + } + + [TestMethod] + public async Task ProcessAsync_RejectsUnsupportedClass() + { + using var protocol = new OracleDnsProtocol(new StubHandler(_ => throw new InvalidOperationException("Should not send when class is invalid"))); + (OracleResponseCode code, string message) = await protocol.ProcessAsync(new Uri("dns:example.com?CLASS=CH"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Error, code); + StringAssert.Contains(message, "class"); + } + + [TestMethod] + public async Task ProcessAsync_AllowsClassIn() + { + var dohResponse = new + { + Status = 0, + Answer = new[] + { + new { name = "example.com.", type = 16, ttl = 120, data = "\"hello\"" } + } + }; + string json = JsonSerializer.Serialize(dohResponse); + var handler = new StubHandler(request => + { + Assert.IsTrue(request.RequestUri.Query.Contains("name=example.com", StringComparison.Ordinal)); + Assert.IsTrue(request.RequestUri.Query.Contains("type=16", StringComparison.Ordinal)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/dns-json") + }; + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string payload) = await protocol.ProcessAsync(new Uri("dns:example.com?CLASS=IN;TYPE=TXT"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + using JsonDocument doc = JsonDocument.Parse(payload); + Assert.AreEqual("TXT", doc.RootElement.GetProperty("Answers")[0].GetProperty("Type").GetString()); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsTooLargeForOversizedCertificate() + { + string base64Cert = Convert.ToBase64String(new byte[OracleResponse.MaxResultSize + 1]); + var dohResponse = new + { + Status = 0, + Answer = new[] + { + new { name = "big.example.com.", type = 16, ttl = 60, data = $"\"{base64Cert}\"" } + } + }; + string json = JsonSerializer.Serialize(dohResponse); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/dns-json") + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string payload) = await protocol.ProcessAsync(new Uri("dns:big.example.com?FORMAT=x509"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.ResponseTooLarge, code); + Assert.IsNull(payload); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsCertificateFromTxtRecord() + { + string base64Cert = GenerateCertificateBase64("CN=example.com"); + var dohResponse = new + { + Status = 0, + Answer = new[] + { + new { name = "oracle.example.com.", type = 16, ttl = 60, data = $"\"{base64Cert}\"" } + } + }; + string json = JsonSerializer.Serialize(dohResponse); + var handler = new StubHandler(request => + { + Assert.IsTrue(request.RequestUri.Query.Contains("name=oracle.example.com", StringComparison.Ordinal)); + Assert.IsTrue(request.RequestUri.Query.Contains("type=16", StringComparison.Ordinal)); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/dns-json") + }; + return response; + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string payload) = await protocol.ProcessAsync(new Uri("dns:oracle.example.com?TYPE=TXT;FORMAT=x509"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + Assert.IsNotNull(payload); + using JsonDocument doc = JsonDocument.Parse(payload); + Assert.AreEqual("oracle.example.com", doc.RootElement.GetProperty("Name").GetString()); + var certElement = doc.RootElement.GetProperty("Certificate"); + Assert.AreEqual(base64Cert, certElement.GetProperty("Der").GetString()); + using var parsedCert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(base64Cert)); + var pkElement = certElement.GetProperty("PublicKey"); + string expectedPublicKey = Convert.ToBase64String(parsedCert.GetPublicKey()); + string expectedAlgorithm = parsedCert.PublicKey.Oid?.FriendlyName ?? parsedCert.PublicKey.Oid?.Value; + Assert.AreEqual(expectedPublicKey, pkElement.GetProperty("Encoded").GetString()); + Assert.AreEqual(expectedAlgorithm, pkElement.GetProperty("Algorithm").GetString()); + using RSA rsa = parsedCert.GetRSAPublicKey(); + Assert.IsNotNull(rsa); + RSAParameters parameters = rsa.ExportParameters(false); + Assert.AreEqual(Convert.ToHexString(parameters.Modulus), pkElement.GetProperty("Modulus").GetString()); + Assert.AreEqual(Convert.ToHexString(parameters.Exponent), pkElement.GetProperty("Exponent").GetString()); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsEcPublicKeyFields() + { + string base64Cert = GenerateEcCertificateBase64("CN=example-ec.com"); + var dohResponse = new + { + Status = 0, + Answer = new[] + { + new { name = "ec.example.com.", type = 16, ttl = 60, data = $"\"{base64Cert}\"" } + } + }; + string json = JsonSerializer.Serialize(dohResponse); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/dns-json") + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string payload) = await protocol.ProcessAsync(new Uri("dns:ec.example.com?TYPE=TXT;FORMAT=x509"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + using JsonDocument doc = JsonDocument.Parse(payload); + var pkElement = doc.RootElement.GetProperty("Certificate").GetProperty("PublicKey"); + using var parsedCert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(base64Cert)); + using ECDsa ecdsa = parsedCert.GetECDsaPublicKey(); + Assert.IsNotNull(ecdsa); + ECParameters parameters = ecdsa.ExportParameters(false); + string expectedCurve = parameters.Curve.Oid?.FriendlyName ?? parameters.Curve.Oid?.Value; + Assert.AreEqual(expectedCurve, pkElement.GetProperty("Curve").GetString()); + Assert.AreEqual(Convert.ToHexString(parameters.Q.X), pkElement.GetProperty("X").GetString()); + Assert.AreEqual(Convert.ToHexString(parameters.Q.Y), pkElement.GetProperty("Y").GetString()); + } + + [TestMethod] + public async Task ProcessAsync_ReturnsNotFoundForNxDomain() + { + const string response = "{\"Status\":3}"; + var handler = new StubHandler(request => + { + Assert.IsTrue(request.RequestUri.Query.Contains("type=1", StringComparison.Ordinal)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(response, Encoding.UTF8, "application/dns-json") + }; + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string payload) = await protocol.ProcessAsync(new Uri("dns:example.com"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.NotFound, code); + Assert.IsNull(payload); + } + + [TestMethod] + public async Task ProcessAsync_OmitsCertificateWhenNotRequested() + { + var dohResponse = new + { + Status = 0, + Answer = new[] + { + new { name = "plain.example.com.", type = 16, ttl = 120, data = "\"hello\"" } + } + }; + string json = JsonSerializer.Serialize(dohResponse); + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/dns-json") + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string payload) = await protocol.ProcessAsync(new Uri("dns:plain.example.com?TYPE=TXT"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + using JsonDocument doc = JsonDocument.Parse(payload); + Assert.IsFalse(doc.RootElement.TryGetProperty("Certificate", out _)); + } + + [TestMethod] + public async Task ProcessAsync_ParsesCloudflareTxtExample() + { + const string dohResponse = """ +{ + "Status": 0, + "TC": false, + "RD": true, + "RA": true, + "AD": true, + "CD": false, + "Question": [ + { "name": "1alhai._domainkey.icloud.com.", "type": 16 } + ], + "Answer": [ + { + "name": "1alhai._domainkey.icloud.com.", + "type": 16, + "TTL": 299, + "data": "\"k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp1+6V9wVDqveufqdpypuXn7Z1xXHrp236UMtO4Zwzp1KimG1HjMATkUMlzUxr87hcPLZ9eczsQnUnxE27XGr0C+MEY0S8NxVkg4CSkiUbSSjMBDuNIQP5CKEM5Qn2ATqNnS/xPbbGr3HdWu3UwG+329xNXO/SuKD5d/mswHxZ34rnOG0r8QwMCKaRZ3eLaxhUJW6QcgO5Kb/6VQwWi4KFOeFHrgb3R04QLbTjaCj1eO0MJdHj7FVGHvXZHzVvzJeY9q24apqYh6gMPkTFogyXv3gZH/BqhGlymM4T/6QAEyy6AdZkGouVp21Hb+Jseb3CidRubc4QZAlWTMwVzKhI6+wIDAQAB\"" + } + ], + "Comment": "Mocked Cloudflare DoH response" +} +"""; + const string expectedTxt = "\"k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp1+6V9wVDqveufqdpypuXn7Z1xXHrp236UMtO4Zwzp1KimG1HjMATkUMlzUxr87hcPLZ9eczsQnUnxE27XGr0C+MEY0S8NxVkg4CSkiUbSSjMBDuNIQP5CKEM5Qn2ATqNnS/xPbbGr3HdWu3UwG+329xNXO/SuKD5d/mswHxZ34rnOG0r8QwMCKaRZ3eLaxhUJW6QcgO5Kb/6VQwWi4KFOeFHrgb3R04QLbTjaCj1eO0MJdHj7FVGHvXZHzVvzJeY9q24apqYh6gMPkTFogyXv3gZH/BqhGlymM4T/6QAEyy6AdZkGouVp21Hb+Jseb3CidRubc4QZAlWTMwVzKhI6+wIDAQAB\""; + var handler = new StubHandler(request => + { + Assert.IsTrue(request.Headers.Accept.Any(h => h.MediaType == "application/dns-json")); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(dohResponse, Encoding.UTF8, "application/dns-json") + }; + }); + using var protocol = new OracleDnsProtocol(handler); + (OracleResponseCode code, string payload) = await protocol.ProcessAsync(new Uri("dns:1alhai._domainkey.icloud.com?TYPE=TXT"), CancellationToken.None); + Assert.AreEqual(OracleResponseCode.Success, code); + using JsonDocument doc = JsonDocument.Parse(payload); + Assert.AreEqual("1alhai._domainkey.icloud.com", doc.RootElement.GetProperty("Name").GetString()); + var answers = doc.RootElement.GetProperty("Answers"); + Assert.AreEqual(1, answers.GetArrayLength()); + Assert.AreEqual("TXT", answers[0].GetProperty("Type").GetString()); + Assert.AreEqual(expectedTxt, answers[0].GetProperty("Data").GetString()); + } + + private static void LoadSettings() + { + var values = new Dictionary + { + ["PluginConfiguration:Network"] = "5195086", + ["PluginConfiguration:Nodes:0"] = "http://127.0.0.1:20332", + ["PluginConfiguration:AllowedContentTypes:0"] = "application/json", + ["PluginConfiguration:Https:Timeout"] = "5000", + ["PluginConfiguration:NeoFS:EndPoint"] = "http://127.0.0.1:8080", + ["PluginConfiguration:NeoFS:Timeout"] = "15000", + ["PluginConfiguration:Dns:EndPoint"] = "https://unit.test/dns-query", + ["PluginConfiguration:Dns:Timeout"] = "3000" + }; + IConfigurationSection section = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build() + .GetSection("PluginConfiguration"); + OracleSettings.Load(section); + } + + private static string GenerateCertificateBase64(string subject) + { + using RSA rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + return Convert.ToBase64String(certificate.Export(X509ContentType.Cert)); + } + + private static string GenerateEcCertificateBase64(string subject) + { + using ECDsa ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest(subject, ecdsa, HashAlgorithmName.SHA256); + using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + return Convert.ToBase64String(certificate.Export(X509ContentType.Cert)); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func responder; + + public StubHandler(Func responder) + { + this.responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(responder(request)); + } + } +}