From 16baf27c2e46aa4720daef2209753f3ad7db4a7d Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 07:51:56 -0600 Subject: [PATCH 01/22] fix(transport): fork lane zones from the transport zone A queued request started from _release ran inside the finishing lane's zone, so each new lane's zone was parented on its predecessor. The predecessor's error handler captures its lane, keeping every retired HttpClient and ID bitmap reachable under sustained queued load. Co-Authored-By: Claude Opus 5.5 --- lib/src/io_transport.dart | 5 ++++- test/io_connection_reuse_test.dart | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart index a6684ba..129146a 100644 --- a/lib/src/io_transport.dart +++ b/lib/src/io_transport.dart @@ -31,6 +31,9 @@ class IoDohTransport implements DohTransport, DohWireTransport { final _waiting = Queue<_IoRequest>(); final _running = <_IoRequest>{}; final _idle = <_Lane>[]; + // Lanes fork from here: forking from Zone.current during a queued handoff + // would parent each new lane on its predecessor and keep the chain alive. + final _zone = Zone.current; IoDohTransport( {this.proxy, this.proxyDestination, @@ -138,7 +141,7 @@ class IoDohTransport implements DohTransport, DohWireTransport { _Lane _newLane(_Origin origin) { final lane = _Lane(origin); - lane.zone = Zone.current.fork(specification: + lane.zone = _zone.fork(specification: ZoneSpecification(handleUncaughtError: (_, __, ___, error, stack) { if (lane.owner case final owner?) { owner._finish( diff --git a/test/io_connection_reuse_test.dart b/test/io_connection_reuse_test.dart index 590f156..c01d9c7 100644 --- a/test/io_connection_reuse_test.dart +++ b/test/io_connection_reuse_test.dart @@ -327,6 +327,21 @@ void main() { expect(other.connections, 1); }); + test('queued handoffs do not chain lane zones', () async { + await serve(answer); + final overrides = _ZoneRecordingOverrides(); + final lanes = await HttpOverrides.runWithHttpOverrides(() async { + final t = + transport(null, reuseConnections: false, maxConcurrentRequests: 1); + await Future.wait([for (var id = 1; id <= 4; id++) expectAnswer(t, id)]); + return overrides.zones; + }, overrides); + expect(lanes, hasLength(4)); + expect(lanes.map((zone) => zone.parent).toSet(), hasLength(1), + reason: 'each lane must fork from the transport zone, not its ' + 'predecessor, or retired lanes stay reachable'); + }); + test('idle connections are released without close', () async { await serve(answer); final t = transport(null, idleTimeout: const Duration(milliseconds: 200)); @@ -380,3 +395,12 @@ void main() { await until(() => server.closed == 1); }); } + +class _ZoneRecordingOverrides extends HttpOverrides { + final zones = []; + @override + HttpClient createHttpClient(SecurityContext? context) { + zones.add(Zone.current); + return super.createHttpClient(context); + } +} From 2e9c92141549f8e0b136807175e71104dd4fb113 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:29:55 -0600 Subject: [PATCH 02/22] feat!: reduce DNS TTLs by HTTP Age and zero high-bit TTLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8484 §5.1 requires DoH clients to reduce DNS TTLs by the response's HTTP Age. IoDohTransport now parses Age per RFC 9111 §5.1: the first member of a list-valued or repeated header, delta-seconds only, leading zeros allowed, saturating at 2^31. It is carried on DohHttpResponse.age, and DohResolver subtracts it from JSON answer TTLs, floored at zero, keeping it on DnsResponse.age. RFC 2181 §8 treats a received TTL with the most significant bit set as zero. JSON answers and issued local evidence previously kept values up to 0xffffffff as positive lifetimes. Wire bodies are unchanged; callers must apply the age themselves. BREAKING CHANGE: TTLs of 2^31 or more are now reported as 0, and DohHttpResponse and DnsResponse gain an age field. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++++ lib/src/dns.dart | 33 ++++++++++++++++++++------- lib/src/io_transport.dart | 14 +++++++++++- lib/src/resolver.dart | 13 +++++++---- test/dns_test.dart | 45 ++++++++++++++++++++++++++++++++++++- test/io_transport_test.dart | 45 +++++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7b17d2d..de1f707 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,10 @@ only for the same HTTPS origin and while transaction IDs remain unique on that connection; malformed, cancelled, timed-out, or failed exchanges retire the connection. `maxConcurrentRequests` and `maxQueuedRequests` bound admitted work; a full queue fails new operations with `DnsError.transport`. + +## Cache age + +RFC 8484 requires DNS TTLs to be reduced by the HTTP `Age` of a response. +`DohResolver` applies this to JSON answers, flooring TTLs at zero. Wire-format +bodies are returned unmodified; callers must subtract `DohHttpResponse.age` +from any TTL they decode. diff --git a/lib/src/dns.dart b/lib/src/dns.dart index 23eb3d0..66a3900 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; enum DnsError { invalidName, @@ -27,6 +28,9 @@ class DnsException implements Exception { final _label = RegExp(r'^[a-z0-9_-]+$'); +// RFC 2181 §8: a received TTL with the most significant bit set means zero. +int _ttl(int ttl) => ttl > 0x7fffffff ? 0 : ttl; + String normalizeDnsName(String name) { if (name.codeUnits.any((c) => c > 127)) { throw const DnsException(DnsError.invalidName, "Use ASCII DNS names"); @@ -124,8 +128,8 @@ final class DnsValidationIssuer { throw const DnsException( DnsError.malformedResponse, 'Invalid validated CNAME chain'); } - chain.add( - DnsRecord(name: owner, type: 5, ttl: alias.ttl, data: alias.data)); + chain.add(DnsRecord( + name: owner, type: 5, ttl: _ttl(alias.ttl), data: alias.data)); owner = normalizeDnsName(alias.data); if (!seen.add(owner)) { throw const DnsException(DnsError.aliasLoop, 'CNAME loop'); @@ -141,7 +145,7 @@ final class DnsValidationIssuer { resolver, [ for (final r in records) - DnsTxtRecord(name: canonicalName, ttl: r.ttl, bytes: r.bytes) + DnsTxtRecord(name: canonicalName, ttl: _ttl(r.ttl), bytes: r.bytes) ], chain, null, @@ -175,11 +179,23 @@ class DnsResponse { final bool? truncated; final List questions; final List answers; - DnsResponse._(this.resolver, this.status, this.authenticatedData, - this.checkingDisabled, this.truncated, this.questions, this.answers); + // Answer TTLs are already reduced by this HTTP cache age. + final Duration age; + DnsResponse._( + this.resolver, + this.status, + this.authenticatedData, + this.checkingDisabled, + this.truncated, + this.questions, + this.answers, + this.age); factory DnsResponse.fromJson(Object? json, - {required Uri resolver, int maxAnswers = 64}) { + {required Uri resolver, + int maxAnswers = 64, + Duration age = Duration.zero}) { + if (age < Duration.zero) throw ArgumentError.value(age, 'age'); const malformed = DnsException(DnsError.malformedResponse, 'Invalid DNS JSON response'); if (json is! Map || json['Status'] is! int) { @@ -236,7 +252,7 @@ class DnsResponse { answers.add(DnsRecord( name: name(a['name']), type: a['type'] as int, - ttl: a['TTL'] as int, + ttl: max(0, _ttl(a['TTL'] as int) - age.inSeconds), data: a['data'] as String)); } return DnsResponse._( @@ -246,7 +262,8 @@ class DnsResponse { flag('CD'), flag('TC'), List.unmodifiable(questions), - List.unmodifiable(answers)); + List.unmodifiable(answers), + age); } AuthenticatedTxtResult authenticatedTxt(String domain, diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart index 129146a..6949b11 100644 --- a/lib/src/io_transport.dart +++ b/lib/src/io_transport.dart @@ -226,6 +226,17 @@ class IoDohTransport implements DohTransport, DohWireTransport { } } +// RFC 9111 §5.1: use the first member of a list-valued or repeated Age, and +// ignore it if that member is not delta-seconds. Values beyond 2^31 saturate. +Duration _age(List? values) { + if (values == null || values.isEmpty) return Duration.zero; + final first = values.first.split(',').first.trim(); + if (!RegExp(r'^[0-9]+$').hasMatch(first)) return Duration.zero; + final digits = first.replaceFirst(RegExp(r'^0+'), ''); + final seconds = digits.length > 10 ? null : int.parse('0$digits'); + return Duration(seconds: min(seconds ?? 0x80000000, 0x80000000)); +} + typedef _Origin = ({String scheme, String host, int port}); _Origin _origin(Uri uri) => ( @@ -331,7 +342,8 @@ class _IoRequest { } final body = bytes.takeBytes(); if (query != null) validateDnsWireResponse(query!, body); - _finish(DohHttpResponse(response.statusCode, body)); + _finish(DohHttpResponse(response.statusCode, body, + age: _age(response.headers[HttpHeaders.ageHeader]))); } catch (error, stack) { _finish( error is DnsException diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index bcdd4b3..a2b47c0 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -11,8 +11,12 @@ class DnsOperation { class DohHttpResponse { final int statusCode; final List body; - DohHttpResponse(this.statusCode, List body) - : body = List.unmodifiable(body); + // Time the response spent in HTTP caches; DNS TTLs must be reduced by it. + final Duration age; + DohHttpResponse(this.statusCode, List body, {this.age = Duration.zero}) + : body = List.unmodifiable(body) { + if (age < Duration.zero) throw ArgumentError.value(age, 'age'); + } } abstract interface class DohTransport { @@ -96,8 +100,9 @@ class DohResolver { DnsError.responseTooLarge, 'DNS body exceeds limit'); } final body = jsonDecode(utf8.decode(response.body)); - result.complete(DnsResponse.fromJson(body, resolver: endpoint) - .authenticatedTxt(domain)); + result.complete( + DnsResponse.fromJson(body, resolver: endpoint, age: response.age) + .authenticatedTxt(domain)); } on DnsException catch (error, stack) { result.completeError(error, stack); } on FormatException catch (_, stack) { diff --git a/test/dns_test.dart b/test/dns_test.dart index 87c9ae5..e07a9a7 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -189,7 +189,30 @@ void main() { .records .single .ttl, - 0xffffffff); + 0); + }); + test('TTLs with the high bit set are treated as zero', () { + for (final (raw, ttl) in [(0x7fffffff, 0x7fffffff), (0x80000000, 0)]) { + final result = decode(reply(answers: [ + rr('alice.example', 5, 'pay.example')..['TTL'] = raw, + rr('pay.example', 16, 'x')..['TTL'] = raw, + ])).authenticatedTxt('alice.example'); + expect(result.records.single.ttl, ttl); + expect(result.aliases.single.ttl, ttl); + final issued = DnsValidationIssuer('test').issueTxt( + question: 'alice.example', + canonicalName: 'pay.example', + resolver: Uri.https('resolver.example', '/dns-query'), + records: [ + DnsTxtRecord(name: 'pay.example', ttl: raw, bytes: [1]) + ], + aliases: [ + DnsRecord( + name: 'alice.example', type: 5, ttl: raw, data: 'pay.example') + ]); + expect(issued.records.single.ttl, ttl); + expect(issued.aliases.single.ttl, ttl); + } }); test('malformed and overlong TXT cannot turn into recipient data', () { for (final s in [ @@ -278,6 +301,26 @@ void main() { await Future.delayed(Duration.zero); }); } + test('HTTP cache age reduces TTLs and never drives them negative', () async { + for (final (age, ttl) in [(0, 60), (25, 35), (60, 0), (250, 0)]) { + final transport = FakeTransport(); + final result = + DohResolver(transport: transport).lookupTxt('alice.example'); + transport.completer.complete(DohHttpResponse( + 200, + utf8.encode(jsonEncode(reply(answers: [ + rr('alice.example', 5, 'pay.example'), + rr('pay.example', 16, 'x'), + ]))), + age: Duration(seconds: age))); + final txt = await result; + expect(txt.records.single.ttl, ttl); + expect(txt.aliases.single.ttl, ttl); + expect(txt.response!.age, Duration(seconds: age)); + } + expect(() => DohHttpResponse(200, [], age: const Duration(seconds: -1)), + throwsArgumentError); + }); test('rejects HTTP, JSON and injected response limit violations', () async { for (final response in [ DohHttpResponse(302, []), diff --git a/test/io_transport_test.dart b/test/io_transport_test.dart index 7087a07..5965935 100644 --- a/test/io_transport_test.dart +++ b/test/io_transport_test.dart @@ -190,6 +190,51 @@ void main() { .lookupTxt('alice.example'); expect(result.records, hasLength(1)); }); + for (final (header, seconds) in [ + (null, 0), + ('250', 250), + (' 7 ', 7), + ('000000000001', 1), + ('00000000000000000000', 0), + ('2147483648', 0x80000000), + ('99999999999', 0x80000000), + ('000099999999999', 0x80000000), + ('250, 0', 250), + ('5,abc', 5), + (', 5', 0), + ('-5', 0), + ('1.5', 0), + ('abc', 0), + ]) { + test('reports HTTP Age header $header as $seconds seconds', () async { + server.listen((r) { + r.response.headers.contentType = ContentType.json; + if (header != null) { + r.response.headers.set(HttpHeaders.ageHeader, header); + } + r.response.write(jsonEncode(reply())); + r.response.close(); + }); + final response = await IoDohTransport(securityContext: trust) + .start(endpoint(), maxResponseBytes: 65536) + .result; + expect(response.age, Duration(seconds: seconds)); + }); + } + test('uses the first of repeated HTTP Age headers', () async { + server.listen((r) { + r.response.headers + ..contentType = ContentType.json + ..add(HttpHeaders.ageHeader, '250') + ..add(HttpHeaders.ageHeader, '0'); + r.response.write(jsonEncode(reply())); + r.response.close(); + }); + final response = await IoDohTransport(securityContext: trust) + .start(endpoint(), maxResponseBytes: 65536) + .result; + expect(response.age, const Duration(seconds: 250)); + }); for (final (type, encoding) in [ (null, null), (ContentType.text, null), From ebde7d04e69a0c7e95f4a4a769c61a6422296a5f Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:30:12 -0600 Subject: [PATCH 03/22] fix(dns): reject lossy or out-of-range TXT data JSON permits unpaired surrogate escapes, and utf8.encode replaced them with U+FFFD, so the decoded octets differed from what the resolver sent. Reject them as malformed instead. DnsTxtRecord also accepted any int, so caller-built evidence passed to DnsValidationIssuer.issueTxt could carry values outside 0-255. Validate in the constructor so every record holds real TXT octets. Co-Authored-By: Claude Opus 5.5 --- lib/src/dns.dart | 19 +++++++++++++++++-- test/dns_test.dart | 6 +++++- test/local_evidence_test.dart | 13 +++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/src/dns.dart b/lib/src/dns.dart index 66a3900..32c4655 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -70,7 +70,11 @@ class DnsTxtRecord { final List bytes; DnsTxtRecord( {required this.name, required this.ttl, required List bytes}) - : bytes = List.unmodifiable(bytes); + : bytes = List.unmodifiable(bytes) { + if (this.bytes.any((byte) => byte < 0 || byte > 255)) { + throw ArgumentError.value(bytes, 'bytes', 'TXT data must be octets'); + } + } String get text { try { @@ -330,9 +334,20 @@ List decodeDnsTxtBytes(String value, {int maxPresentationLength = 8192}) { throw const DnsException( DnsError.responseTooLarge, 'TXT presentation exceeds limit'); } - if (!value.startsWith('"')) return utf8.encode(value); const malformed = DnsException(DnsError.malformedResponse, 'Malformed TXT presentation'); + // utf8.encode would silently turn these into U+FFFD, altering the octets. + for (var i = 0; i < value.length; i++) { + final unit = value.codeUnitAt(i); + if (unit & 0xfc00 == 0xdc00) throw malformed; + if (unit & 0xfc00 == 0xd800) { + if (i + 1 == value.length || value.codeUnitAt(i + 1) & 0xfc00 != 0xdc00) { + throw malformed; + } + i++; + } + } + if (!value.startsWith('"')) return utf8.encode(value); final bytes = []; var i = 0; while (i < value.length) { diff --git a/test/dns_test.dart b/test/dns_test.dart index e07a9a7..785738d 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -222,7 +222,11 @@ void main() { r'"\12"', r'"\1"', r'"\1x2"', - '"trailing\\' + '"trailing\\', + 'lone \ud800', + '"lone \udc00"', + '"\\\ud800"', + '"\ud800 x"', ]) { expect(() => decodeDnsTxtBytes(s), throwsA(error(DnsError.malformedResponse))); diff --git a/test/local_evidence_test.dart b/test/local_evidence_test.dart index bf5d8d1..0e437f4 100644 --- a/test/local_evidence_test.dart +++ b/test/local_evidence_test.dart @@ -72,6 +72,19 @@ void main() { expect(result.aliases.single.name, 'alice.example'); }); + test('TXT records hold only octets', () { + for (final bytes in [ + [256], + [-1], + [65, 0x1f600] + ]) { + expect(() => DnsTxtRecord(name: 'bob.example', ttl: 60, bytes: bytes), + throwsArgumentError); + } + expect(DnsTxtRecord(name: 'bob.example', ttl: 60, bytes: [0, 255]).bytes, + [0, 255]); + }); + test('resolver-asserted results name no local validator', () { final response = DnsResponse.fromJson({ 'Status': 0, From 8f04264ae5fbab547f005810f3a3f456c9a6f7cb Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:30:35 -0600 Subject: [PATCH 04/22] refactor!: report every failure as a sealed DnsException via the result DnsException was one class carrying a DnsError enum code, plus a responseCode that only meant something for two codes. It is now a sealed base class with a final subtype per failure, so callers can catch one case with an on-clause or switch exhaustively. CNAME failures share a sealed DnsAliasException parent. DnsQueryFailedException carries the DNS rcode and DnsTransportException an optional httpStatus. The unused noRecords code is gone; an authenticated empty answer still succeeds with no records. Errors also reached callers two ways: invalid names threw from startTxtLookup and invalid wire queries or URIs threw ArgumentError, while a closed transport or full queue failed the returned future. Starting an operation now never throws; all of these complete DnsOperation.result. The new DnsInvalidQueryException covers unusable wire queries and request URIs, including from validateDnsWireResponse and padDnsWireQuery. ArgumentError remains for invalid constructor arguments only. Every lookup now completes. The response handler caught only DnsException and FormatException while its finally block cancelled the deadline, so any other error left the lookup pending forever; it now maps to DnsMalformedResponseException. A custom transport that throws from start() fails the lookup instead of escaping. The resolver also stops cancelling transport operations that have already completed, so transports no longer need cancel() to be harmless after completion. BREAKING CHANGE: DnsError and DnsException.code/responseCode are removed in favour of DnsException subtypes; start methods no longer throw; invalid wire queries raise DnsInvalidQueryException, not ArgumentError. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++- example/doh_resolver_example.dart | 2 +- lib/src/dns.dart | 138 ++++++++++++++++++----------- lib/src/io_transport.dart | 61 ++++++------- lib/src/resolver.dart | 64 ++++++++----- lib/src/wire.dart | 16 ++-- test/dns_test.dart | 122 ++++++++++++++++++++----- test/io_connection_reuse_test.dart | 29 +++--- test/io_transport_test.dart | 21 +++-- test/io_wire_transport_test.dart | 51 +++++++---- test/wire_test.dart | 12 +-- 11 files changed, 348 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index de1f707..dddbf7a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,19 @@ reused because JSON has no transaction identifier. Wire connections are reused only for the same HTTPS origin and while transaction IDs remain unique on that connection; malformed, cancelled, timed-out, or failed exchanges retire the connection. `maxConcurrentRequests` and `maxQueuedRequests` bound admitted -work; a full queue fails new operations with `DnsError.transport`. +work; a full queue fails new operations with `DnsTransportException`. + +## Errors + +Every failure is a subtype of the sealed `DnsException`, so callers can catch +a specific case or switch over all of them exhaustively. `DnsQueryFailedException` +carries the DNS `rcode`; `DnsTransportException` carries `httpStatus` when the +resolver returned a non-200 response. + +Starting an operation never throws. Invalid names, URIs and wire queries, +a closed transport, and a full queue all complete `DnsOperation.result` with +a `DnsException`. `ArgumentError` is reserved for invalid constructor +configuration. ## Cache age diff --git a/example/doh_resolver_example.dart b/example/doh_resolver_example.dart index 237129f..fe3d921 100644 --- a/example/doh_resolver_example.dart +++ b/example/doh_resolver_example.dart @@ -15,7 +15,7 @@ Future main(List arguments) async { print('${record.name} TTL=${record.ttl}: ${record.text}'); } } on DnsException catch (error) { - print('Lookup rejected: ${error.code.name}'); + print('Lookup rejected: $error'); } } finally { await transport.close(); diff --git a/lib/src/dns.dart b/lib/src/dns.dart index 32c4655..49d0d95 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -1,29 +1,76 @@ import 'dart:convert'; import 'dart:math'; -enum DnsError { - invalidName, - malformedResponse, - responseTooLarge, - queryFailed, - authenticationUnavailable, - questionMismatch, - aliasLoop, - aliasAmbiguous, - aliasTooLong, - noRecords, - transport, - cancelled, - timedOut, -} - -class DnsException implements Exception { - final DnsError code; +/// Every failure this package reports. Switch over it exhaustively, or catch +/// a specific subtype. +sealed class DnsException implements Exception { final String message; - final int? responseCode; - const DnsException(this.code, this.message, {this.responseCode}); + const DnsException(this.message); @override - String toString() => 'DnsException(${code.name}): $message'; + String toString() => '$runtimeType: $message'; +} + +final class DnsInvalidNameException extends DnsException { + const DnsInvalidNameException(super.message); +} + +/// A caller-supplied wire query or request URI was unusable. +final class DnsInvalidQueryException extends DnsException { + const DnsInvalidQueryException(super.message); +} + +final class DnsMalformedResponseException extends DnsException { + const DnsMalformedResponseException(super.message); +} + +final class DnsResponseTooLargeException extends DnsException { + const DnsResponseTooLargeException(super.message); +} + +/// The resolver answered with a non-zero DNS RCODE, such as 3 (NXDOMAIN). +final class DnsQueryFailedException extends DnsException { + final int rcode; + const DnsQueryFailedException(super.message, {required this.rcode}); +} + +final class DnsAuthenticationUnavailableException extends DnsException { + const DnsAuthenticationUnavailableException(super.message); +} + +final class DnsQuestionMismatchException extends DnsException { + const DnsQuestionMismatchException(super.message); +} + +/// The answer's CNAME chain could not be followed to a single owner. +sealed class DnsAliasException extends DnsException { + const DnsAliasException(super.message); +} + +final class DnsAliasLoopException extends DnsAliasException { + const DnsAliasLoopException(super.message); +} + +final class DnsAliasAmbiguousException extends DnsAliasException { + const DnsAliasAmbiguousException(super.message); +} + +final class DnsAliasTooLongException extends DnsAliasException { + const DnsAliasTooLongException(super.message); +} + +/// The HTTPS exchange failed; [httpStatus] is set when a non-200 status +/// was received. +final class DnsTransportException extends DnsException { + final int? httpStatus; + const DnsTransportException(super.message, {this.httpStatus}); +} + +final class DnsCancelledException extends DnsException { + const DnsCancelledException(super.message); +} + +final class DnsTimeoutException extends DnsException { + const DnsTimeoutException(super.message); } final _label = RegExp(r'^[a-z0-9_-]+$'); @@ -33,7 +80,7 @@ int _ttl(int ttl) => ttl > 0x7fffffff ? 0 : ttl; String normalizeDnsName(String name) { if (name.codeUnits.any((c) => c > 127)) { - throw const DnsException(DnsError.invalidName, "Use ASCII DNS names"); + throw const DnsInvalidNameException("Use ASCII DNS names"); } final value = (name.endsWith('.') ? name.substring(0, name.length - 1) : name) .toLowerCase(); @@ -41,7 +88,7 @@ String normalizeDnsName(String name) { value.length > 253 || !value.split('.').every((label) => label.isNotEmpty && label.length <= 63 && _label.hasMatch(label))) { - throw const DnsException(DnsError.invalidName, 'Invalid ASCII DNS name'); + throw const DnsInvalidNameException('Invalid ASCII DNS name'); } return value; } @@ -80,7 +127,7 @@ class DnsTxtRecord { try { return utf8.decode(bytes); } on FormatException { - throw const DnsException(DnsError.malformedResponse, 'TXT is not UTF-8'); + throw const DnsMalformedResponseException('TXT is not UTF-8'); } } } @@ -118,8 +165,7 @@ final class DnsValidationIssuer { normalizeDnsName(r.name) != canonicalName || r.ttl < 0 || r.ttl > 0xffffffff)) { - throw const DnsException( - DnsError.malformedResponse, 'Invalid validated TXT result'); + throw const DnsMalformedResponseException('Invalid validated TXT result'); } var owner = question; final seen = {owner}; @@ -129,19 +175,19 @@ final class DnsValidationIssuer { normalizeDnsName(alias.name) != owner || alias.ttl < 0 || alias.ttl > 0xffffffff) { - throw const DnsException( - DnsError.malformedResponse, 'Invalid validated CNAME chain'); + throw const DnsMalformedResponseException( + 'Invalid validated CNAME chain'); } chain.add(DnsRecord( name: owner, type: 5, ttl: _ttl(alias.ttl), data: alias.data)); owner = normalizeDnsName(alias.data); if (!seen.add(owner)) { - throw const DnsException(DnsError.aliasLoop, 'CNAME loop'); + throw const DnsAliasLoopException('CNAME loop'); } } if (owner != canonicalName) { - throw const DnsException( - DnsError.questionMismatch, 'CNAME chain does not reach TXT owner'); + throw const DnsQuestionMismatchException( + 'CNAME chain does not reach TXT owner'); } return AuthenticatedTxtResult._( question, @@ -201,7 +247,7 @@ class DnsResponse { Duration age = Duration.zero}) { if (age < Duration.zero) throw ArgumentError.value(age, 'age'); const malformed = - DnsException(DnsError.malformedResponse, 'Invalid DNS JSON response'); + DnsMalformedResponseException('Invalid DNS JSON response'); if (json is! Map || json['Status'] is! int) { throw malformed; } @@ -219,8 +265,7 @@ class DnsResponse { throw malformed; } if (rawAnswers.length > maxAnswers) { - throw const DnsException( - DnsError.responseTooLarge, 'Too many DNS answers'); + throw const DnsResponseTooLargeException('Too many DNS answers'); } String name(Object? input) { if (input is! String) throw malformed; @@ -274,27 +319,25 @@ class DnsResponse { {int maxOwnerVisits = 8}) { domain = normalizeDnsName(domain); if (status != 0) { - throw DnsException(DnsError.queryFailed, 'DNS query failed', - responseCode: status); + throw DnsQueryFailedException('DNS query failed', rcode: status); } if (authenticatedData != true || checkingDisabled != false || truncated != false) { - throw const DnsException(DnsError.authenticationUnavailable, + throw const DnsAuthenticationUnavailableException( 'Authenticated complete DNS response required'); } if (questions.length != 1 || questions.single.type != 16 || questions.single.name != domain) { - throw const DnsException( - DnsError.questionMismatch, 'DNS question mismatch'); + throw const DnsQuestionMismatchException('DNS question mismatch'); } var owner = domain; final seen = {}; final aliases = []; for (var visit = 0; visit < maxOwnerVisits; visit++) { if (!seen.add(owner)) { - throw const DnsException(DnsError.aliasLoop, 'CNAME loop'); + throw const DnsAliasLoopException('CNAME loop'); } final records = answers.where((record) => record.name == owner); final cnames = records.where((record) => record.type == 5).toList(); @@ -312,30 +355,25 @@ class DnsResponse { this); } if (cnames.length != 1 || txt.isNotEmpty) { - throw const DnsException( - DnsError.aliasAmbiguous, 'Ambiguous CNAME owner'); + throw const DnsAliasAmbiguousException('Ambiguous CNAME owner'); } aliases.add(cnames.single); try { owner = normalizeDnsName(cnames.single.data); } on DnsException { - throw const DnsException( - DnsError.malformedResponse, 'Invalid CNAME target'); + throw const DnsMalformedResponseException('Invalid CNAME target'); } } - throw const DnsException( - DnsError.aliasTooLong, 'CNAME chain exceeds owner limit'); + throw const DnsAliasTooLongException('CNAME chain exceeds owner limit'); } } // DNS decimal escapes encode octets; decode UTF-8 only after joining TXT chunks. List decodeDnsTxtBytes(String value, {int maxPresentationLength = 8192}) { if (value.length > maxPresentationLength) { - throw const DnsException( - DnsError.responseTooLarge, 'TXT presentation exceeds limit'); + throw const DnsResponseTooLargeException('TXT presentation exceeds limit'); } - const malformed = - DnsException(DnsError.malformedResponse, 'Malformed TXT presentation'); + const malformed = DnsMalformedResponseException('Malformed TXT presentation'); // utf8.encode would silently turn these into U+FFFD, altering the octets. for (var i = 0; i < value.length; i++) { final unit = value.codeUnitAt(i); diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart index 6949b11..09b1450 100644 --- a/lib/src/io_transport.dart +++ b/lib/src/io_transport.dart @@ -66,8 +66,14 @@ class IoDohTransport implements DohTransport, DohWireTransport { @override DnsOperation startWire(Uri uri, List query, {required int maxResponseBytes}) { - return _start(uri, min(maxResponseBytes, 65535), - padQueries ? padDnsWireQuery(query) : checkedDnsWireQuery(query)); + final List checked; + try { + checked = + padQueries ? padDnsWireQuery(query) : checkedDnsWireQuery(query); + } on DnsException catch (error) { + return DnsOperation.failed(error); + } + return _start(uri, min(maxResponseBytes, 65535), checked); } Future close() async { @@ -87,20 +93,17 @@ class IoDohTransport implements DohTransport, DohWireTransport { uri.userInfo.isNotEmpty || uri.hasFragment || maxResponseBytes < 1) { - throw ArgumentError('Expected a bounded HTTPS request'); + return DnsOperation.failed( + const DnsInvalidQueryException('Expected a bounded HTTPS request')); } if (_closed) { - return DnsOperation( - Future.error( - const DnsException(DnsError.cancelled, 'DNS transport closed')), - () {}); + return DnsOperation.failed( + const DnsCancelledException('DNS transport closed')); } if (_active >= maxConcurrentRequests && _waiting.length >= maxQueuedRequests) { - return DnsOperation( - Future.error(const DnsException( - DnsError.transport, 'DNS transport request queue is full')), - () {}); + return DnsOperation.failed( + const DnsTransportException('DNS transport request queue is full')); } final request = _IoRequest(this, uri, maxResponseBytes, query); if (_active < maxConcurrentRequests) { @@ -145,9 +148,7 @@ class IoDohTransport implements DohTransport, DohWireTransport { ZoneSpecification(handleUncaughtError: (_, __, ___, error, stack) { if (lane.owner case final owner?) { owner._finish( - const DnsException( - DnsError.transport, 'Resolver connection failed'), - stack); + const DnsTransportException('Resolver connection failed'), stack); } else { _retire(lane); } @@ -168,8 +169,8 @@ class IoDohTransport implements DohTransport, DohWireTransport { owner._done || _origin(destination) != lane.origin || _origin(owner.uri) != lane.origin) { - throw const DnsException( - DnsError.cancelled, 'DNS connection cancelled or redirected'); + throw const DnsCancelledException( + 'DNS connection cancelled or redirected'); } final task = SocksConnection.start( proxyHost: proxy.host, @@ -271,10 +272,8 @@ class _IoRequest { late final Timer _deadline; bool _done = false; _IoRequest(this.transport, this.uri, this.limit, this.query) { - _deadline = Timer( - transport.requestTimeout, - () => _finish( - const DnsException(DnsError.timedOut, 'DNS request timed out'))); + _deadline = Timer(transport.requestTimeout, + () => _finish(const DnsTimeoutException('DNS request timed out'))); } void _finish(Object outcome, [StackTrace? stack]) { @@ -290,7 +289,7 @@ class _IoRequest { } void cancel() => - _finish(const DnsException(DnsError.cancelled, 'DNS request cancelled')); + _finish(const DnsCancelledException('DNS request cancelled')); Future run() async { if (_done) return; @@ -312,30 +311,27 @@ class _IoRequest { } final response = await request.close(); if (response.statusCode != 200) { - throw DnsException(DnsError.transport, 'Resolver HTTP request failed', - responseCode: response.statusCode); + throw DnsTransportException('Resolver HTTP request failed', + httpStatus: response.statusCode); } final mime = response.headers.contentType?.mimeType; if (query == null ? mime != 'application/json' && mime != 'application/dns-json' : mime != 'application/dns-message') { - throw const DnsException( - DnsError.malformedResponse, 'Unexpected DNS response media type'); + throw const DnsMalformedResponseException( + 'Unexpected DNS response media type'); } if (response.headers.value(HttpHeaders.contentEncodingHeader) case final encoding? when encoding.toLowerCase() != 'identity') { - throw const DnsException( - DnsError.malformedResponse, 'Encoded DNS response'); + throw const DnsMalformedResponseException('Encoded DNS response'); } if (response.contentLength > limit) { - throw const DnsException( - DnsError.responseTooLarge, 'DNS body exceeds limit'); + throw const DnsResponseTooLargeException('DNS body exceeds limit'); } final bytes = BytesBuilder(copy: false); await for (final chunk in response) { if (bytes.length + chunk.length > limit) { - throw const DnsException( - DnsError.responseTooLarge, 'DNS body exceeds limit'); + throw const DnsResponseTooLargeException('DNS body exceeds limit'); } bytes.add(chunk); if (_done) return; @@ -348,8 +344,7 @@ class _IoRequest { _finish( error is DnsException ? error - : const DnsException( - DnsError.transport, 'Resolver connection failed'), + : const DnsTransportException('Resolver connection failed'), stack); } } diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index a2b47c0..3016b73 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -2,12 +2,17 @@ import 'dart:async'; import 'dart:convert'; import 'dns.dart'; +/// A started request. Starting never throws: every failure, including +/// invalid input, completes [result] with a [DnsException]. class DnsOperation { final Future result; final void Function() cancel; const DnsOperation(this.result, this.cancel); + DnsOperation.failed(DnsException error) : this(Future.error(error), _noop); } +void _noop() {} + class DohHttpResponse { final int statusCode; final List body; @@ -55,7 +60,11 @@ class DohResolver { startTxtLookup(domain).result; DnsOperation startTxtLookup(String domain) { - domain = normalizeDnsName(domain); + try { + domain = normalizeDnsName(domain); + } on DnsException catch (error) { + return DnsOperation.failed(error); + } final parameters = { ...endpoint.queryParameters, 'name': '$domain.', @@ -69,35 +78,40 @@ class DohResolver { final length = endpoint.replace(queryParameters: parameters).toString().length; parameters['random_padding'] = 'X' * (1 + (128 - length % 128) % 128); - final operation = transport.start( - endpoint.replace(queryParameters: parameters), - maxResponseBytes: maxResponseBytes); + final DnsOperation operation; + try { + operation = transport.start(endpoint.replace(queryParameters: parameters), + maxResponseBytes: maxResponseBytes); + } on DnsException catch (error) { + return DnsOperation.failed(error); + } catch (_) { + return DnsOperation.failed( + const DnsTransportException('DNS transport failed')); + } final result = Completer(); late Timer timer; - void release() { + // Only an unfinished transport operation is cancelled; once its result + // has completed, the transport owns no further work for this lookup. + void abort(DnsException error) { + if (result.isCompleted) return; + result.completeError(error); timer.cancel(); try { operation.cancel(); } catch (_) {} } - void abort(DnsError code) { - if (result.isCompleted) return; - result.completeError(DnsException(code, 'DNS query ${code.name}')); - release(); - } - - timer = Timer(timeout, () => abort(DnsError.timedOut)); + timer = Timer( + timeout, () => abort(const DnsTimeoutException('DNS query timed out'))); operation.result.then((response) { if (result.isCompleted) return; try { if (response.statusCode != 200) { - throw DnsException(DnsError.transport, 'DNS HTTPS request failed', - responseCode: response.statusCode); + throw DnsTransportException('DNS HTTPS request failed', + httpStatus: response.statusCode); } if (response.body.length > maxResponseBytes) { - throw const DnsException( - DnsError.responseTooLarge, 'DNS body exceeds limit'); + throw const DnsResponseTooLargeException('DNS body exceeds limit'); } final body = jsonDecode(utf8.decode(response.body)); result.complete( @@ -107,23 +121,27 @@ class DohResolver { result.completeError(error, stack); } on FormatException catch (_, stack) { result.completeError( - const DnsException( - DnsError.malformedResponse, 'Invalid DNS JSON encoding'), + const DnsMalformedResponseException('Invalid DNS JSON encoding'), + stack); + } catch (_, stack) { + // The deadline is cancelled below, so every outcome must complete here. + result.completeError( + const DnsMalformedResponseException('Unreadable DNS response'), stack); } finally { - release(); + timer.cancel(); } }, onError: (Object error, StackTrace stack) { - release(); + timer.cancel(); if (!result.isCompleted) { result.completeError( error is DnsException ? error - : const DnsException( - DnsError.transport, 'DNS transport failed'), + : const DnsTransportException('DNS transport failed'), stack); } }); - return DnsOperation(result.future, () => abort(DnsError.cancelled)); + return DnsOperation(result.future, + () => abort(const DnsCancelledException('DNS query cancelled'))); } } diff --git a/lib/src/wire.dart b/lib/src/wire.dart index fb065e9..f4a7d33 100644 --- a/lib/src/wire.dart +++ b/lib/src/wire.dart @@ -2,22 +2,20 @@ import 'dart:typed_data'; import 'dns.dart'; -const _malformed = - DnsException(DnsError.malformedResponse, 'Malformed DNS wire message'); -const _mismatch = DnsException( - DnsError.questionMismatch, 'DNS wire response does not match query'); +const _malformed = DnsMalformedResponseException('Malformed DNS wire message'); +const _mismatch = + DnsQuestionMismatchException('DNS wire response does not match query'); /// This is framing only: it never makes the answer trustworthy. void validateDnsWireResponse(List query, List response) { final sent = _parseOrNull(query); if (sent == null || sent.isResponse) { - throw ArgumentError('Invalid DNS wire query'); + throw const DnsInvalidQueryException('Invalid DNS wire query'); } final reply = _WireMessage.parse(response); if (!reply.isResponse) throw _malformed; if (reply.flags & 0x0200 != 0) { - throw const DnsException( - DnsError.malformedResponse, 'Truncated DNS wire response'); + throw const DnsMalformedResponseException('Truncated DNS wire response'); } if (reply.id != sent.id || reply.opcode != sent.opcode || @@ -38,7 +36,7 @@ List padDnsWireQuery(List query, {int blockSize = 128}) { } final message = _parseOrNull(query); if (message == null || message.isResponse) { - throw ArgumentError('Invalid DNS wire query'); + throw const DnsInvalidQueryException('Invalid DNS wire query'); } final rdlength = message.optRdlengthOffset; if (rdlength == null || !message.optIsLast || message.optHasPadding) { @@ -64,7 +62,7 @@ List padDnsWireQuery(List query, {int blockSize = 128}) { List checkedDnsWireQuery(List query) { final message = _parseOrNull(query); if (message == null || message.isResponse) { - throw ArgumentError('Invalid DNS wire query'); + throw const DnsInvalidQueryException('Invalid DNS wire query'); } return List.unmodifiable(query); } diff --git a/test/dns_test.dart b/test/dns_test.dart index 785738d..b6b3c16 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -22,8 +22,6 @@ Map rr(String name, int type, String data) => { }; DnsResponse decode(Object? json) => DnsResponse.fromJson(json, resolver: Uri.parse('https://dns.example/resolve')); -Matcher error(DnsError code) => - isA().having((e) => e.code, 'code', code); class FakeTransport implements DohTransport { final completer = Completer(); @@ -64,14 +62,43 @@ void main() { reply()..[field] = field != 'AD' ]) { expect(() => decode(body).authenticatedTxt('alice.example'), - throwsA(error(DnsError.authenticationUnavailable))); + throwsA(isA())); } }); } test('DNS error preserves status without inferring bogus or unsigned', () { expect( () => decode(reply()..['Status'] = 2).authenticatedTxt('alice.example'), - throwsA(error(DnsError.queryFailed))); + throwsA( + isA().having((e) => e.rcode, 'rcode', 2))); + }); + test('HTTP failures carry the status', () async { + final transport = FakeTransport(); + final result = DohResolver(transport: transport).lookupTxt('alice.example'); + transport.completer.complete(DohHttpResponse(503, [])); + await expectLater( + result, + throwsA(isA() + .having((e) => e.httpStatus, 'httpStatus', 503))); + }); + test('the exception hierarchy is sealed and exhaustive', () { + String describe(DnsException error) => switch (error) { + DnsInvalidNameException() => 'name', + DnsInvalidQueryException() => 'query', + DnsMalformedResponseException() => 'malformed', + DnsResponseTooLargeException() => 'large', + DnsQueryFailedException(:final rcode) => 'rcode $rcode', + DnsAuthenticationUnavailableException() => 'unauthenticated', + DnsQuestionMismatchException() => 'mismatch', + DnsAliasException() => 'alias', + DnsTransportException(:final httpStatus) => 'http $httpStatus', + DnsCancelledException() => 'cancelled', + DnsTimeoutException() => 'timeout', + }; + expect(describe(const DnsAliasLoopException('loop')), 'alias'); + expect(describe(const DnsQueryFailedException('x', rcode: 3)), 'rcode 3'); + expect(const DnsTimeoutException('late').toString(), + 'DnsTimeoutException: late'); }); for (final questions in [ [], @@ -90,7 +117,7 @@ void main() { expect( () => decode(reply()..['Question'] = questions) .authenticatedTxt('alice.example'), - throwsA(error(DnsError.questionMismatch))); + throwsA(isA())); }); } test('no answers is distinct from DNS failure', () { @@ -104,7 +131,7 @@ void main() { expect( () => decode(reply(answers: [rr('alice.example', 5, 'alice.example')])) .authenticatedTxt('alice.example'), - throwsA(error(DnsError.aliasLoop))); + throwsA(isA())); for (final records in [ [rr('alice.example', 5, 'pay.example'), rr('alice.example', 16, 'txt')], [ @@ -115,7 +142,7 @@ void main() { expect( () => decode(reply(answers: records)).authenticatedTxt('alice.example'), - throwsA(error(DnsError.aliasAmbiguous))); + throwsA(isA())); } final chain = [ rr('alice.example', 5, 'hop0.example'), @@ -124,7 +151,7 @@ void main() { ]; expect( () => decode(reply(answers: chain)).authenticatedTxt('alice.example'), - throwsA(error(DnsError.aliasTooLong))); + throwsA(isA())); }); test('rejects invalid TTLs, names, record data, types and response shape', () { @@ -149,7 +176,7 @@ void main() { expect( () => decode(reply( answers: List.generate(65, (_) => rr('alice.example', 16, 'x')))), - throwsA(error(DnsError.responseTooLarge))); + throwsA(isA())); }); test('TXT chunks and decimal UTF-8 octets are decoded without loss', () { expect( @@ -161,7 +188,7 @@ void main() { expect(decodeDnsTxtBytes(r'"\255"'), [255]); expect(utf8.decode(decodeDnsTxtBytes('"\\😀"')), '😀'); expect(() => DnsTxtRecord(name: 'x', ttl: 1, bytes: [255]).text, - throwsA(error(DnsError.malformedResponse))); + throwsA(isA())); }); test('validated evidence enforces DNS TTL bounds', () { final issuer = DnsValidationIssuer('test'); @@ -174,7 +201,7 @@ void main() { records: [ DnsTxtRecord(name: 'alice.example', ttl: ttl, bytes: [1]) ]), - throwsA(error(DnsError.malformedResponse))); + throwsA(isA())); } expect( issuer @@ -229,10 +256,10 @@ void main() { '"\ud800 x"', ]) { expect(() => decodeDnsTxtBytes(s), - throwsA(error(DnsError.malformedResponse))); + throwsA(isA())); } expect(() => decodeDnsTxtBytes('a' * 8193), - throwsA(error(DnsError.responseTooLarge))); + throwsA(isA())); }); test('DNS names allow service labels without permitting alias syntax', () { expect(normalizeDnsName('_Dmarc.Example.'), '_dmarc.example'); @@ -242,8 +269,8 @@ void main() { 'a..example', 'https://example.org' ]) { - expect( - () => normalizeDnsName(name), throwsA(error(DnsError.invalidName))); + expect(() => normalizeDnsName(name), + throwsA(isA())); } }); test('requests privacy flags and propagates evidence', () async { @@ -263,7 +290,9 @@ void main() { .complete(DohHttpResponse(200, utf8.encode(jsonEncode(reply())))); expect((await query.result).records.single.text, 'oa1:xmr recipient_address=valid;'); - expect(transport.cancelled, isTrue); + query.cancel(); + expect(transport.cancelled, isFalse, + reason: 'a completed transport operation is never cancelled'); }); test('padding hides name length and keeps endpoint parameters', () { final lengths = {}; @@ -279,12 +308,32 @@ void main() { } expect(lengths, {128}); }); + test('an invalid name fails the result without reaching the transport', + () async { + final transport = FakeTransport(); + final operation = + DohResolver(transport: transport).startTxtLookup('a..example'); + await expectLater( + operation.result, throwsA(isA())); + operation.cancel(); + expect(transport.uri, isNull); + }); + test('a transport that throws on start fails the result', () async { + for (final (thrown, expected) in [ + (const DnsCancelledException('closed'), isA()), + (StateError('broken'), isA()), + ]) { + final result = DohResolver(transport: _ThrowingTransport(thrown)) + .lookupTxt('a.example'); + await expectLater(result, throwsA(expected)); + } + }); test('a throwing transport cancel cannot escape the deadline', () async { final transport = FakeTransport()..throwOnCancel = true; final query = DohResolver( transport: transport, timeout: const Duration(milliseconds: 20)) .startTxtLookup('alice.example'); - await expectLater(query.result, throwsA(error(DnsError.timedOut))); + await expectLater(query.result, throwsA(isA())); query.cancel(); }); for (final cancel in [true, false]) { @@ -295,8 +344,11 @@ void main() { final resolver = DohResolver( transport: transport, timeout: const Duration(milliseconds: 30)); final query = resolver.startTxtLookup('alice.example'); - final failed = expectLater(query.result, - throwsA(error(cancel ? DnsError.cancelled : DnsError.timedOut))); + final failed = expectLater( + query.result, + throwsA(cancel + ? isA() + : isA())); if (cancel) query.cancel(); await failed; expect(transport.cancelled, isTrue); @@ -325,6 +377,13 @@ void main() { expect(() => DohHttpResponse(200, [], age: const Duration(seconds: -1)), throwsArgumentError); }); + test('an unexpected parsing error still completes the lookup', () async { + final transport = FakeTransport(); + final result = DohResolver(transport: transport).lookupTxt('alice.example'); + transport.completer.complete(_ThrowingResponse()); + await expectLater(result.timeout(const Duration(seconds: 1)), + throwsA(isA())); + }); test('rejects HTTP, JSON and injected response limit violations', () async { for (final response in [ DohHttpResponse(302, []), @@ -336,7 +395,30 @@ void main() { DohResolver(transport: transport).lookupTxt('alice.example'); transport.completer.complete(response); await expectLater(result, throwsA(isA())); - expect(transport.cancelled, isTrue); + expect(transport.cancelled, isFalse); } + final failing = FakeTransport(); + final failed = DohResolver(transport: failing).lookupTxt('alice.example'); + failing.completer.completeError(StateError('down')); + await expectLater(failed, throwsA(isA())); + expect(failing.cancelled, isFalse); }); } + +class _ThrowingResponse implements DohHttpResponse { + @override + int get statusCode => 200; + @override + Duration get age => Duration.zero; + @override + List get body => throw StateError('broken body'); +} + +class _ThrowingTransport implements DohTransport { + final Object error; + _ThrowingTransport(this.error); + @override + DnsOperation start(Uri uri, + {required int maxResponseBytes}) => + throw error; +} diff --git a/test/io_connection_reuse_test.dart b/test/io_connection_reuse_test.dart index c01d9c7..7a1b6e3 100644 --- a/test/io_connection_reuse_test.dart +++ b/test/io_connection_reuse_test.dart @@ -5,7 +5,6 @@ import 'dart:io'; import 'package:doh_resolver/doh_resolver_io.dart'; import 'package:test/test.dart'; -import 'dns_test.dart' show error; import 'support/dns_wire.dart'; import 'support/scripted_server.dart'; import 'support/test_pki.dart'; @@ -99,42 +98,42 @@ void main() { ) in [ ( 'an empty-body 404', - DnsError.transport, + isA(), false, (ScriptedExchange e, Completer _) async => e.send(httpResponse([], status: 404)) ), ( 'a JSON-typed reply', - DnsError.malformedResponse, + isA(), false, (ScriptedExchange e, Completer _) async => e.send(httpResponse(wireReply(id: e.id), type: 'application/json')) ), ( 'a buffered reply with another ID', - DnsError.questionMismatch, + isA(), false, (ScriptedExchange e, Completer _) async => e.send(httpResponse(wireReply(id: e.id ^ 1))) ), ( 'a chunked reply with another ID', - DnsError.questionMismatch, + isA(), false, (ScriptedExchange e, Completer _) async => e.send(httpResponse(wireReply(id: e.id ^ 1), chunked: true)) ), ( 'a chunked reply over the size limit', - DnsError.responseTooLarge, + isA(), false, (ScriptedExchange e, Completer _) async => e.send(httpResponse(List.filled(600, 0), chunked: true)) ), ( 'a timed-out exchange answered late with an extra response', - DnsError.timedOut, + isA(), false, (ScriptedExchange e, Completer _) async { await Future.delayed(const Duration(milliseconds: 500)); @@ -144,7 +143,7 @@ void main() { ), ( 'a cancelled exchange answered late with an extra response', - DnsError.cancelled, + isA(), true, (ScriptedExchange e, Completer ready) async { ready.complete(); @@ -155,7 +154,7 @@ void main() { ), ( 'a reply cancelled mid-body and then completed with an extra response', - DnsError.cancelled, + isA(), true, (ScriptedExchange e, Completer ready) async { final reply = httpResponse(wireReply(id: e.id)); @@ -175,7 +174,7 @@ void main() { final t = transport(proxy, requestTimeout: const Duration(milliseconds: 300)); final operation = start(t, 0x1111); - final failed = expectLater(operation.result, throwsA(error(code))); + final failed = expectLater(operation.result, throwsA(code)); if (cancel) { await ready.future; operation.cancel(); @@ -199,7 +198,8 @@ void main() { final t = transport(proxy, maxConcurrentRequests: 2); final a = start(t, 1); final b = start(t, 2); - final aFailed = expectLater(a.result, throwsA(error(DnsError.cancelled))); + final aFailed = + expectLater(a.result, throwsA(isA())); await until(() => server.exchanges.length == 2); final held = server.exchanges.firstWhere((e) => e.id == 2); a.cancel(); @@ -362,13 +362,14 @@ void main() { final queued = start(t, 3); final failed = [ for (final operation in [inFlight, queued]) - expectLater(operation.result, throwsA(error(DnsError.cancelled))) + expectLater(operation.result, throwsA(isA())) ]; await until(() => server.exchanges.length == 2); await t.close(); await Future.wait(failed); await until(() => server.closed == 1); - await expectLater(start(t, 4).result, throwsA(error(DnsError.cancelled))); + await expectLater( + start(t, 4).result, throwsA(isA())); expect(server.connections, 1); }); @@ -379,7 +380,7 @@ void main() { addTearDown(proxy.close); final t = transport(proxy); final failed = - expectLater(start(t, 1).result, throwsA(error(DnsError.cancelled))); + expectLater(start(t, 1).result, throwsA(isA())); await proxy.connected.future; await t.close(); await failed; diff --git a/test/io_transport_test.dart b/test/io_transport_test.dart index 5965935..0e49733 100644 --- a/test/io_transport_test.dart +++ b/test/io_transport_test.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:doh_resolver/doh_resolver_io.dart'; import 'package:test/test.dart'; -import 'dns_test.dart' show reply, error; +import 'dns_test.dart' show reply; import 'support/test_pki.dart'; import 'support/tunnel_proxy.dart'; @@ -74,7 +74,7 @@ void main() { securityContext: trust, proxy: SocksProxy(InternetAddress.loopbackIPv4, proxy.port))); await expectLater(resolver.lookupTxt('alice.example'), - throwsA(error(DnsError.transport))); + throwsA(isA())); await Future.delayed(const Duration(milliseconds: 50)); expect(requests, 0); expect(proxy.connections, 1); @@ -84,7 +84,7 @@ void main() { await expectLater( DohResolver(endpoint: endpoint(), transport: IoDohTransport()) .lookupTxt('alice.example'), - throwsA(error(DnsError.transport))); + throwsA(isA())); }); test('redirects never produce a second request', () async { var requests = 0; @@ -100,7 +100,7 @@ void main() { endpoint: endpoint(), transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'), - throwsA(error(DnsError.transport))); + throwsA(isA())); expect(requests, 1); }); test('streaming response size limit aborts before parsing', () async { @@ -115,7 +115,7 @@ void main() { maxResponseBytes: 128, transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'), - throwsA(error(DnsError.responseTooLarge))); + throwsA(isA())); }); test('unsigned response is not accepted merely because HTTPS succeeded', () async { @@ -129,7 +129,7 @@ void main() { endpoint: endpoint(), transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'), - throwsA(error(DnsError.authenticationUnavailable))); + throwsA(isA())); }); for (final cancel in [false, true]) { test('${cancel ? 'cancel' : 'timeout'} destroys stalled response transport', @@ -151,8 +151,11 @@ void main() { securityContext: trust, proxy: SocksProxy(InternetAddress.loopbackIPv4, proxy.port))); final operation = resolver.startTxtLookup('alice.example'); - final failed = expectLater(operation.result, - throwsA(error(cancel ? DnsError.cancelled : DnsError.timedOut))); + final failed = expectLater( + operation.result, + throwsA(cancel + ? isA() + : isA())); await sent.future; if (cancel) operation.cancel(); await failed; @@ -255,7 +258,7 @@ void main() { endpoint: endpoint(), transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'), - throwsA(error(DnsError.malformedResponse))); + throwsA(isA())); }); } } diff --git a/test/io_wire_transport_test.dart b/test/io_wire_transport_test.dart index 32205bb..f3665e4 100644 --- a/test/io_wire_transport_test.dart +++ b/test/io_wire_transport_test.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:doh_resolver/doh_resolver_io.dart'; import 'package:test/test.dart'; -import 'dns_test.dart' show error; import 'support/dns_wire.dart'; import 'support/test_pki.dart'; import 'support/tunnel_proxy.dart'; @@ -82,51 +81,71 @@ void main() { expect(sent, query); }); - test('rejects invalid wire queries before connecting', () { + test('rejects invalid queries and URIs through the result, unconnected', + () async { + var requests = 0; + server.listen((_) => requests++); final transport = IoDohTransport(securityContext: pki.clientContext()); for (final query in [ [], wireReply(), [...wireQuery(), 0] ]) { - expect( - () => transport.startWire(endpoint(), query, maxResponseBytes: 512), - throwsArgumentError); + await expectLater( + transport.startWire(endpoint(), query, maxResponseBytes: 512).result, + throwsA(isA())); + } + for (final uri in [ + endpoint().replace(scheme: 'http'), + endpoint().replace(userInfo: 'user'), + endpoint().replace(fragment: 'x'), + ]) { + await expectLater( + transport.startWire(uri, wireQuery(), maxResponseBytes: 512).result, + throwsA(isA())); + await expectLater(transport.start(uri, maxResponseBytes: 512).result, + throwsA(isA())); } + await expectLater( + transport + .startWire(endpoint(), wireQuery(), maxResponseBytes: 0) + .result, + throwsA(isA())); + expect(requests, 0); }); for (final (name, code, status, type, reply) in [ ( 'mismatched ID', - DnsError.questionMismatch, + isA(), 200, 'application/dns-message', (List q) => wireReply(id: (q[0] << 8 | q[1]) ^ 1) ), ( 'another question', - DnsError.questionMismatch, + isA(), 200, 'application/dns-message', (List q) => wireReply(name: 'b.example', id: q[0] << 8 | q[1]) ), ( 'truncated reply', - DnsError.malformedResponse, + isA(), 200, 'application/dns-message', (List q) => wireReply(id: q[0] << 8 | q[1], flags: 0x8380) ), ( 'JSON content type', - DnsError.malformedResponse, + isA(), 200, 'application/json', (List q) => wireReply(id: q[0] << 8 | q[1]) ), ( 'server error', - DnsError.transport, + isA(), 500, 'application/dns-message', (List q) => wireReply(id: q[0] << 8 | q[1]) @@ -138,7 +157,7 @@ void main() { IoDohTransport(securityContext: pki.clientContext()) .startWire(endpoint(), wireQuery(), maxResponseBytes: 65535) .result, - throwsA(error(code))); + throwsA(code)); }); } @@ -165,7 +184,7 @@ void main() { transport .startWire(endpoint(), wireQuery(), maxResponseBytes: 512) .result, - throwsA(error(DnsError.timedOut))); + throwsA(isA())); expect(arrived.isCompleted, isTrue); if (proxied) { await proxy.disconnected.future.timeout(const Duration(seconds: 2)); @@ -201,9 +220,9 @@ void main() { ]; await until(() => arrivals == 2); operations[3].cancel(); - expect(await outcomes[3], error(DnsError.cancelled)); + expect(await outcomes[3], isA()); operations[0].cancel(); - expect(await outcomes[0], error(DnsError.cancelled)); + expect(await outcomes[0], isA()); await until(() => arrivals == 3); for (final request in held.skip(1)) { final body = await request.fold>([], (a, b) => a..addAll(b)); @@ -251,9 +270,9 @@ void main() { transport .startWire(endpoint(), wireQuery(id: 4), maxResponseBytes: 512) .result, - throwsA(error(DnsError.transport))); + throwsA(isA())); final cancelledResult = - expectLater(cancelled.result, throwsA(error(DnsError.cancelled))); + expectLater(cancelled.result, throwsA(isA())); cancelled.cancel(); await cancelledResult; final replacement = transport.startWire(endpoint(), wireQuery(id: 5), diff --git a/test/wire_test.dart b/test/wire_test.dart index 96e0819..977b28f 100644 --- a/test/wire_test.dart +++ b/test/wire_test.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:doh_resolver/doh_resolver.dart'; import 'package:test/test.dart'; -import 'dns_test.dart' show error; import 'support/dns_wire.dart'; const pointer = [0xc0, 12]; @@ -88,7 +87,7 @@ void main() { validateDnsWireResponse(query, wireReply(name: 'ALICE.example')); expect( () => validateDnsWireResponse(query, wireReply(name: 'alicf.example')), - throwsA(error(DnsError.questionMismatch))); + throwsA(isA())); }); test('rejects replies that do not answer the query', () { @@ -101,7 +100,7 @@ void main() { wireReply()..[query.length - 11 - 1] = 3, ]) { expect(() => validateDnsWireResponse(query, reply), - throwsA(error(DnsError.questionMismatch))); + throwsA(isA())); } }); @@ -112,10 +111,10 @@ void main() { wireReply()..[query.length - 5] = 1, ]) { expect(() => validateDnsWireResponse(query, reply), - throwsA(error(DnsError.malformedResponse))); + throwsA(isA())); } expect(() => validateDnsWireResponse(wireReply(), wireReply()), - throwsArgumentError); + throwsA(isA())); }); group('hostile messages fail closed', () { @@ -250,7 +249,8 @@ void main() { wireReply(), [...query]..[0] = 256, ]) { - expect(() => padDnsWireQuery(bad), throwsArgumentError); + expect(() => padDnsWireQuery(bad), + throwsA(isA())); } }); }); From 18fe33b16319997651e504a07979e9ddfbb1bea2 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:31:08 -0600 Subject: [PATCH 05/22] feat(transport)!: own wire IDs and stop reusing connections via proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8484 §4.1 says DoH clients SHOULD use DNS ID 0, but a connection refused any ID it had already carried, so every ID 0 query after the first opened a new connection and reuse never happened. The transport now gives each exchange a random ID unique on its connection, validates the reply against the query as sent, and restores the caller's ID in the returned body. A stale reply on a reused connection still fails the ID check rather than answering the next request. A connection retires once half its ID space is used, keeping random selection to about two draws. SIG(0) (RFC 2931 §3.1) and TSIG sign the header, so queries carrying either in their additional section are sent byte-for-byte with their own ID, which likewise never repeats on a connection. Padding already skipped them because the signature, not OPT, is the last record. A SIG(0)-signed response to an unsigned query (RFC 2931 §2.4) covers the response header and the request as sent, so startWire also takes preserveMessage: the query goes out unpadded with its own ID and the response is returned exactly as received. Lookups sharing a TLS connection reach the resolver together, so it can link them, and through SOCKS they also share one stream, defeating per-lookup isolation such as SocksCredentials.isolation. Reuse now defaults to on only for direct transports. The concurrency test assumed requests reached the server in start order, but concurrent TLS handshakes race, so it intermittently answered the wrong request and timed out. It now keys held requests by question name. BREAKING CHANGE: proxied transports no longer reuse connections unless reuseConnections: true is passed, servers see transport-chosen IDs unless the message is preserved, and DohWireTransport.startWire gains a preserveMessage parameter. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++- lib/src/io_transport.dart | 86 ++++++++++++++++----- lib/src/resolver.dart | 5 +- lib/src/wire.dart | 6 ++ test/io_connection_reuse_test.dart | 118 ++++++++++++++++++++++++++--- test/io_wire_transport_test.dart | 29 ++++--- test/support/dns_wire.dart | 27 +++++++ 7 files changed, 240 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index dddbf7a..5b99b25 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,14 @@ a numeric `proxyDestination`. Call `close()` when the transport is no longer needed. JSON connections are not reused because JSON has no transaction identifier. Wire connections are reused -only for the same HTTPS origin and while transaction IDs remain unique on that -connection; malformed, cancelled, timed-out, or failed exchanges retire the +only for the same HTTPS origin. The transport gives each exchange a fresh +random transaction ID on the wire, unique on its connection, and restores the +caller's ID in the returned body, so callers may send ID 0 as RFC 8484 +recommends. Queries signed with SIG(0) or TSIG are sent byte-for-byte with +their own ID, because the signature covers the header; that ID is likewise +never repeated on a connection. Reuse defaults to off when a `proxy` is set, because +queries sharing a connection can be linked by the resolver; pass +`reuseConnections: true` to opt in. Malformed, cancelled, timed-out, or failed exchanges retire the connection. `maxConcurrentRequests` and `maxQueuedRequests` bound admitted work; a full queue fails new operations with `DnsTransportException`. diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart index 09b1450..63e38e4 100644 --- a/lib/src/io_transport.dart +++ b/lib/src/io_transport.dart @@ -25,6 +25,8 @@ class IoDohTransport implements DohTransport, DohWireTransport { final int maxConcurrentRequests; final int maxQueuedRequests; final bool padQueries; + // Defaults to off with a proxy: a shared connection lets the resolver link + // lookups that would otherwise travel over separate (e.g. Tor) streams. final bool reuseConnections; int _active = 0; bool _closed = false; @@ -34,6 +36,7 @@ class IoDohTransport implements DohTransport, DohWireTransport { // Lanes fork from here: forking from Zone.current during a queued handoff // would parent each new lane on its predecessor and keep the chain alive. final _zone = Zone.current; + final _random = Random.secure(); IoDohTransport( {this.proxy, this.proxyDestination, @@ -44,7 +47,8 @@ class IoDohTransport implements DohTransport, DohWireTransport { this.maxConcurrentRequests = 8, this.maxQueuedRequests = 64, this.padQueries = true, - this.reuseConnections = true}) { + bool? reuseConnections}) + : reuseConnections = reuseConnections ?? proxy == null { if (proxy == null && proxyDestination != null) { throw ArgumentError('proxyDestination requires an explicit proxy'); } @@ -65,15 +69,19 @@ class IoDohTransport implements DohTransport, DohWireTransport { @override DnsOperation startWire(Uri uri, List query, - {required int maxResponseBytes}) { + {required int maxResponseBytes, bool preserveMessage = false}) { final List checked; + final bool preserved; try { - checked = - padQueries ? padDnsWireQuery(query) : checkedDnsWireQuery(query); + // SIG(0) and TSIG sign the header, so signed queries are always kept. + preserved = preserveMessage || isSignedDnsWireQuery(query); + checked = padQueries && !preserved + ? padDnsWireQuery(query) + : checkedDnsWireQuery(query); } on DnsException catch (error) { return DnsOperation.failed(error); } - return _start(uri, min(maxResponseBytes, 65535), checked); + return _start(uri, min(maxResponseBytes, 65535), checked, preserved); } Future close() async { @@ -87,7 +95,7 @@ class IoDohTransport implements DohTransport, DohWireTransport { } DnsOperation _start(Uri uri, int maxResponseBytes, - [List? query]) { + [List? query, bool preserved = false]) { if (uri.scheme != 'https' || uri.host.isEmpty || uri.userInfo.isNotEmpty || @@ -105,7 +113,7 @@ class IoDohTransport implements DohTransport, DohWireTransport { return DnsOperation.failed( const DnsTransportException('DNS transport request queue is full')); } - final request = _IoRequest(this, uri, maxResponseBytes, query); + final request = _IoRequest(this, uri, maxResponseBytes, query, preserved); if (_active < maxConcurrentRequests) { _active++; _run(request); @@ -119,10 +127,13 @@ class IoDohTransport implements DohTransport, DohWireTransport { _Lane? lane; final origin = _origin(request.uri); if (request.query case final query?) { - final id = query[0] << 8 | query[1]; + final callerId = query[0] << 8 | query[1]; for (var i = _idle.length - 1; i >= 0; i--) { final candidate = _idle[i]; - if (candidate.origin == origin && !candidate.hasUsedWireId(id)) { + if (candidate.origin == origin && + (request.preserved + ? candidate.canClaim(callerId) + : candidate.hasFreeWireIds)) { _idle.removeAt(i); lane = candidate; break; @@ -138,6 +149,23 @@ class IoDohTransport implements DohTransport, DohWireTransport { } lane.owner = request; request.lane = lane; + if (request.query case final query?) { + // The transport owns the ID on the wire. Callers may all send ID 0 as + // RFC 8484 recommends, yet a stale reply on a reused connection still + // fails the ID check instead of answering the next request. Preserved + // queries keep their ID, which is still never repeated on a connection, + // and their response is returned exactly as received. + final callerId = query[0] << 8 | query[1]; + if (request.preserved) { + lane.claimWireId(_random, callerId); + request.sent = query; + } else { + final id = lane.claimWireId(_random); + request.sent = Uint8List.fromList(query) + ..[0] = id >> 8 + ..[1] = id & 0xff; + } + } _running.add(request); lane.zone.run(request.run); } @@ -213,7 +241,6 @@ class IoDohTransport implements DohTransport, DohWireTransport { reuseConnections && !_closed && !lane.retired) { - lane.markWireIdUsed(request.query![0] << 8 | request.query![1]); lane.owner = null; _idle.add(lane); } else { @@ -252,14 +279,32 @@ class _Lane { late final HttpClient client; final tasks = >{}; final _usedWireIds = Uint8List(8192); + var _usedWireIdCount = 0; _IoRequest? owner; bool retired = false; _Lane(this.origin); - bool hasUsedWireId(int id) => _usedWireIds[id >> 3] & (1 << (id & 7)) != 0; + // Retiring at half the ID space keeps random selection to ~2 draws. + bool get hasFreeWireIds => _usedWireIdCount < 32768; - void markWireIdUsed(int id) => _usedWireIds[id >> 3] |= 1 << (id & 7); + bool canClaim(int id) => hasFreeWireIds && !_isUsed(id); + + bool _isUsed(int id) => _usedWireIds[id >> 3] & (1 << (id & 7)) != 0; + + /// Claims [id], or a random unused ID when none is given. + int claimWireId(Random random, [int? id]) { + id ??= () { + while (true) { + final candidate = random.nextInt(65536); + if (!_isUsed(candidate)) return candidate; + } + }(); + assert(!_isUsed(id)); + _usedWireIds[id >> 3] |= 1 << (id & 7); + _usedWireIdCount++; + return id; + } } class _IoRequest { @@ -267,11 +312,13 @@ class _IoRequest { final Uri uri; final int limit; final List? query; + List? sent; + final bool preserved; final result = Completer(); _Lane? lane; late final Timer _deadline; bool _done = false; - _IoRequest(this.transport, this.uri, this.limit, this.query) { + _IoRequest(this.transport, this.uri, this.limit, this.query, this.preserved) { _deadline = Timer(transport.requestTimeout, () => _finish(const DnsTimeoutException('DNS request timed out'))); } @@ -304,10 +351,10 @@ class _IoRequest { request.headers.removeAll(HttpHeaders.acceptEncodingHeader); request.headers.set(HttpHeaders.acceptHeader, query == null ? 'application/dns-json' : 'application/dns-message'); - if (query != null) { + if (sent case final sent?) { request.headers.contentType = ContentType('application', 'dns-message'); - request.contentLength = query!.length; - request.add(query!); + request.contentLength = sent.length; + request.add(sent); } final response = await request.close(); if (response.statusCode != 200) { @@ -337,7 +384,12 @@ class _IoRequest { if (_done) return; } final body = bytes.takeBytes(); - if (query != null) validateDnsWireResponse(query!, body); + if (sent case final sent?) { + validateDnsWireResponse(sent, body); + body + ..[0] = query![0] + ..[1] = query![1]; + } _finish(DohHttpResponse(response.statusCode, body, age: _age(response.headers[HttpHeaders.ageHeader]))); } catch (error, stack) { diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index 3016b73..aa230d8 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -29,8 +29,11 @@ abstract interface class DohTransport { } abstract interface class DohWireTransport { + /// Sends wire [query]. With [preserveMessage], the query is sent and the + /// response returned exactly as exchanged, with no padding or ID rewrite, + /// for transaction signatures such as SIG(0) that cover both messages. DnsOperation startWire(Uri uri, List query, - {required int maxResponseBytes}); + {required int maxResponseBytes, bool preserveMessage = false}); } class DohResolver { diff --git a/lib/src/wire.dart b/lib/src/wire.dart index f4a7d33..819ddf2 100644 --- a/lib/src/wire.dart +++ b/lib/src/wire.dart @@ -67,6 +67,9 @@ List checkedDnsWireQuery(List query) { return List.unmodifiable(query); } +bool isSignedDnsWireQuery(List query) => + _parseOrNull(query)?.isSigned ?? false; + _WireMessage? _parseOrNull(List bytes) { try { return _WireMessage.parse(bytes); @@ -102,6 +105,7 @@ class _WireMessage { int? optVersion; bool optIsLast = false; bool optHasPadding = false; + bool isSigned = false; int _offset = 12; _WireMessage._(this.data); @@ -153,6 +157,8 @@ class _WireMessage { final start = _offset + 10; final end = start + rdlength; if (end > data.length) throw _malformed; + // SIG(0) and TSIG sign the header, including the ID. + if (additional && (type == 24 || type == 250)) isSigned = true; if (type == 41) { if (!additional || optRdlengthOffset != null || data[owner] != 0) { throw _malformed; diff --git a/test/io_connection_reuse_test.dart b/test/io_connection_reuse_test.dart index 7a1b6e3..43e2dbd 100644 --- a/test/io_connection_reuse_test.dart +++ b/test/io_connection_reuse_test.dart @@ -50,10 +50,11 @@ void main() { return transport; } - DnsOperation start(IoDohTransport transport, int id) => + DnsOperation start(IoDohTransport transport, int id, + {String name = 'alice.example'}) => transport.startWire( Uri.parse('https://localhost:${server.port}/dns-query'), - wireQuery(id: id), + wireQuery(id: id, name: name), maxResponseBytes: 512); Future until(bool Function() condition) async { @@ -196,16 +197,17 @@ void main() { }); final proxy = await tunnel(proxied); final t = transport(proxy, maxConcurrentRequests: 2); - final a = start(t, 1); - final b = start(t, 2); + final a = start(t, 1, name: 'a.example'); + final b = start(t, 2, name: 'b.example'); final aFailed = expectLater(a.result, throwsA(isA())); await until(() => server.exchanges.length == 2); - final held = server.exchanges.firstWhere((e) => e.id == 2); + final held = + server.exchanges.firstWhere((e) => wireFirstLabel(e.body) == 'b'); a.cancel(); await aFailed; await until(() => server.closed == 1); - answer(held); + held.send(httpResponse(wireEcho(held.body))); expect((await b.result).body.sublist(0, 2), u16(2)); await expectAnswer(t, 3); expect(server.connections, 2); @@ -214,6 +216,82 @@ void main() { }); } + for (final proxied in [false, true]) { + test( + 'reuse defaults ${proxied ? 'off with' : 'on without'} a proxy, ' + 'so proxied lookups are not linked', () async { + await serve(answer); + final proxy = await tunnel(proxied); + final t = IoDohTransport( + securityContext: pki.clientContext(), + proxy: proxy == null + ? null + : SocksProxy(InternetAddress.loopbackIPv4, proxy.port)); + addTearDown(t.close); + expect(t.reuseConnections, !proxied); + for (var id = 1; id <= 3; id++) { + await expectAnswer(t, id); + } + expect(server.connections, proxied ? 3 : 1); + if (proxy != null) expect(proxy.connections, 3); + }); + } + + for (final (name, type) in [('SIG(0)', 24), ('TSIG', 250)]) { + test('$name-signed queries keep their exact bytes and ID', () async { + await serve(answer); + final t = transport(null); + Future send(int id) async { + final query = wireSignedQuery(id: id, type: type); + final response = await t + .startWire( + Uri.parse('https://localhost:${server.port}/dns-query'), query, + maxResponseBytes: 512) + .result; + expect(server.exchanges.last.body, query, + reason: 'the signature covers the header and every record'); + expect(response.body.sublist(0, 2), u16(id)); + } + + await send(0x5150); + await send(0x5150); + expect(server.connections, 2, + reason: 'a preserved ID is still never repeated on one connection'); + await send(0x5151); + expect(server.connections, 2); + }); + } + + test('preserveMessage exchanges an unsigned query byte-for-byte', () async { + // A SIG(0)-signed response covers its header and the request as sent. + final served = >[]; + await serve((e) { + // The echoed OPT plus a stand-in SIG(0) record. + final signed = [ + ...wireEcho(e.body)..[11] = 2, + ...wireRecord([0], 24, [...List.filled(18, 0), 0, 9]), + ]; + served.add(signed); + e.send(httpResponse(signed)); + }); + final t = transport(null); + final query = wireQuery(id: 0x0bad); + Future send() => t + .startWire( + Uri.parse('https://localhost:${server.port}/dns-query'), query, + maxResponseBytes: 512, preserveMessage: true) + .result; + + final response = await send(); + expect(server.exchanges.last.body, query, + reason: 'no padding and no ID rewrite'); + expect(response.body, served.last, + reason: 'the response is returned exactly as signed'); + await send(); + expect(server.connections, 2, + reason: 'a preserved ID is still never repeated on one connection'); + }); + test('an unsolicited response on an idle lane retires only that lane', () async { await serve((e) async { @@ -270,7 +348,18 @@ void main() { expect(server.connections, 2); }); - test('a wire transaction ID is never reused on one connection', () async { + test('repeated caller IDs reuse one connection with distinct wire IDs', + () async { + await serve(answer); + final t = transport(null); + for (var i = 0; i < 4; i++) { + await expectAnswer(t, 0); + } + expect(server.connections, 1); + expect(server.exchanges.map((e) => e.id).toSet(), hasLength(4)); + }); + + test('a stale duplicate reply never answers the next request', () async { await serve((e) async { if (e.index == 1) { e.send(httpResponse(wireReply(id: e.id, flags: 0x8183))); @@ -283,9 +372,18 @@ void main() { } }); final t = transport(null); - expect((await start(t, 0x1111).result).body[3] & 0xf, 3); - expect((await start(t, 0x1111).result).body[3] & 0xf, 0); - expect(server.connections, 2); + expect((await start(t, 0).result).body[3] & 0xf, 3); + // The duplicate is either seen on the idle connection, which is retired, + // or consumed by the next request, whose wire ID it fails to match. + final next = await start(t, 0) + .result + .then((r) => r, onError: (Object e) => e); + if (next is DohHttpResponse) { + expect(next.body[3] & 0xf, 0); + expect(server.connections, 2); + } else { + expect(next, isA()); + } }); test('a connection closed by the server while idle is not reused', () async { diff --git a/test/io_wire_transport_test.dart b/test/io_wire_transport_test.dart index f3665e4..08b6052 100644 --- a/test/io_wire_transport_test.dart +++ b/test/io_wire_transport_test.dart @@ -61,8 +61,9 @@ void main() { expect(response.statusCode, 200); expect(response.body.sublist(0, 2), [0xbe, 0xef]); expect(sent!.length % 128, 0); - expect(sent!.sublist(0, query.length - 2), - query.sublist(0, query.length - 2)); + expect(sent!.sublist(2, query.length - 2), + query.sublist(2, query.length - 2), + reason: 'only the ID and OPT length may change'); if (proxied) { expect(proxy.targetType, 3, reason: 'hostname must reach the proxy'); expect(proxy.target, 'localhost'); @@ -78,7 +79,7 @@ void main() { securityContext: pki.clientContext(), padQueries: false) .startWire(endpoint(), query, maxResponseBytes: 65535) .result; - expect(sent, query); + expect(sent!.sublist(2), query.sublist(2)); }); test('rejects invalid queries and URIs through the result, unconnected', @@ -194,11 +195,14 @@ void main() { test('concurrency limit queues requests and frees slots on every outcome', () async { - final held = []; + // Concurrent handshakes race and the wire ID is the transport's, so key + // held requests by question name. + final held = )>{}; var arrivals = 0; - server.listen((request) { + server.listen((request) async { + final body = await request.fold>([], (a, b) => a..addAll(b)); + held[wireFirstLabel(body)] = (request, body); arrivals++; - held.add(request); }); Future until(bool Function() condition) async { for (var i = 0; i < 200 && !condition(); i++) { @@ -208,10 +212,12 @@ void main() { } final transport = IoDohTransport( - securityContext: pki.clientContext(), maxConcurrentRequests: 2); + securityContext: pki.clientContext(), + maxConcurrentRequests: 2, + requestTimeout: const Duration(seconds: 5)); final operations = [ for (var id = 1; id <= 4; id++) - transport.startWire(endpoint(), wireQuery(id: id), + transport.startWire(endpoint(), wireQuery(id: id, name: 'q$id.example'), maxResponseBytes: 512) ]; final outcomes = [ @@ -224,11 +230,12 @@ void main() { operations[0].cancel(); expect(await outcomes[0], isA()); await until(() => arrivals == 3); - for (final request in held.skip(1)) { - final body = await request.fold>([], (a, b) => a..addAll(b)); + expect(held.keys, unorderedEquals(['q1', 'q2', 'q3'])); + for (final label in ['q2', 'q3']) { + final (request, body) = held[label]!; request.response ..headers.contentType = ContentType('application', 'dns-message') - ..add(wireReply(id: body[0] << 8 | body[1])); + ..add(wireEcho(body)); unawaited(request.response.close()); } expect(await outcomes[1], isA()); diff --git a/test/support/dns_wire.dart b/test/support/dns_wire.dart index 5730356..cc63fb8 100644 --- a/test/support/dns_wire.dart +++ b/test/support/dns_wire.dart @@ -81,3 +81,30 @@ List wireReply( ]; List txtRdata(String text) => [text.length, ...ascii.encode(text)]; + +/// A NOERROR reply that echoes [query]'s ID, question and OPT record. +List wireEcho(List query) => [...query] + ..[2] = 0x81 + ..[3] = 0x80; + +String wireFirstLabel(List message) => + ascii.decode(message.sublist(13, 13 + message[12])); + +/// A query whose last additional record is a SIG(0) (type 24) or TSIG +/// (type 250); both sign the header, including the ID. +List wireSignedQuery({int id = 0x1234, int type = 24}) => [ + ...wireHeader(id: id, ar: 2), + ...wireQuestion(wireName('alice.example')), + ...wireOpt(), + ...wireRecord( + [0], + type, + type == 24 + ? [ + ...u16(0), 13, 0, 0, 0, 0, 0, // + 0, 0, 0, 2, 0, 0, 0, 1, ...u16(7), + ...wireName('key.example'), 1, 2, 3, 4 + ] + : [...wireName('hmac-sha256'), 1, 2, 3, 4], + dnsClass: 255), + ]; From aead529f17bee070640478ddf782a2fc5556800a Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:31:25 -0600 Subject: [PATCH 06/22] fix(resolver): validate endpoint parameters once and keep repeated ones An endpoint such as https://resolver.example/resolve?x=%FF passed construction, but startTxtLookup read endpoint.queryParameters on every call and threw FormatException before returning an operation, breaking the guarantee that starting never throws. The parameters are now decoded once in the constructor, which rejects undecodable endpoints with ArgumentError. queryParameters also kept only the last value of a repeated key, so tag=first&tag=second was sent as tag=second. queryParametersAll keeps every value, while the resolver's own keys still override. Co-Authored-By: Claude Opus 5.5 --- lib/src/resolver.dart | 13 +++++++++++-- test/dns_test.dart | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index aa230d8..2f9a5f3 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -41,6 +41,8 @@ class DohResolver { final Uri endpoint; final Duration timeout; final int maxResponseBytes; + // Decoded once so a malformed endpoint fails construction, not each lookup. + late final Map> _endpointParameters; DohResolver( {required this.transport, Uri? endpoint, @@ -57,6 +59,12 @@ class DohResolver { if (timeout <= Duration.zero || maxResponseBytes < 1) { throw ArgumentError('Invalid query limits'); } + try { + _endpointParameters = this.endpoint.queryParametersAll; + } on FormatException catch (error) { + throw ArgumentError.value( + this.endpoint, 'endpoint', 'Undecodable query: ${error.message}'); + } } Future lookupTxt(String domain) => @@ -68,8 +76,9 @@ class DohResolver { } on DnsException catch (error) { return DnsOperation.failed(error); } - final parameters = { - ...endpoint.queryParameters, + // Repeated endpoint parameters survive; the resolver's own keys override. + final parameters = { + ..._endpointParameters, 'name': '$domain.', 'type': 'TXT', 'do': 'true', diff --git a/test/dns_test.dart b/test/dns_test.dart index b6b3c16..ef6f47c 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -294,6 +294,19 @@ void main() { expect(transport.cancelled, isFalse, reason: 'a completed transport operation is never cancelled'); }); + test('repeated endpoint parameters survive; resolver keys override', () { + final transport = FakeTransport(); + DohResolver( + transport: transport, + endpoint: Uri.parse('https://dns.example/resolve' + '?tag=first&tag=second&name=evil&type=A&type=AAAA')) + .startTxtLookup('alice.example'); + final sent = transport.uri!.queryParametersAll; + expect(sent['tag'], ['first', 'second']); + expect(sent['name'], ['alice.example.']); + expect(sent['type'], ['TXT']); + expect(transport.uri.toString().length % 128, 0); + }); test('padding hides name length and keeps endpoint parameters', () { final lengths = {}; for (final name in ['a.example', 'abcd.example']) { @@ -318,6 +331,17 @@ void main() { operation.cancel(); expect(transport.uri, isNull); }); + test('endpoints with undecodable query parameters are rejected up front', () { + for (final endpoint in [ + 'https://resolver.example/resolve?x=%FF', + 'https://resolver.example/resolve?%C3=1', + ]) { + expect( + () => DohResolver( + transport: FakeTransport(), endpoint: Uri.parse(endpoint)), + throwsArgumentError); + } + }); test('a transport that throws on start fails the result', () async { for (final (thrown, expected) in [ (const DnsCancelledException('closed'), isA()), From fbc3367193b24e8bb2b3ef45d76eff1e1cf64406 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:31:48 -0600 Subject: [PATCH 07/22] feat(resolver)!: decode JSON TXT data in an explicit per-provider format DoH JSON APIs encode TXT data differently: Google returns the text verbatim, Cloudflare returns quoted character-strings with escapes. The decoder guessed from a leading quote, so a Google TXT value starting with " was misparsed or rejected. A value cannot reveal its own form, so DohResolver now takes a DnsTxtFormat (raw or quoted), defaulting for known Google and Cloudflare hosts and required for any other endpoint. The quoted format accepts only quoted character-strings, which is what Cloudflare emits, and requires at least one, so empty or blank data is malformed while "" stays valid. A live test checks both providers decode identically. BREAKING CHANGE: decodeDnsTxtBytes and DnsResponse.authenticatedTxt take a DnsTxtFormat, and DohResolver requires txtFormat for endpoints other than Google and Cloudflare. Co-Authored-By: Claude Opus 5.5 --- lib/src/dns.dart | 30 +++++++-- lib/src/resolver.dart | 27 +++++++- test/dns_test.dart | 104 +++++++++++++++++++++++------ test/io_connection_reuse_test.dart | 1 + test/io_transport_test.dart | 17 ++++- test/live_test.dart | 11 +++ test/local_evidence_test.dart | 3 +- 7 files changed, 163 insertions(+), 30 deletions(-) diff --git a/lib/src/dns.dart b/lib/src/dns.dart index 49d0d95..d5c9816 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -316,7 +316,7 @@ class DnsResponse { } AuthenticatedTxtResult authenticatedTxt(String domain, - {int maxOwnerVisits = 8}) { + {required DnsTxtFormat txtFormat, int maxOwnerVisits = 8}) { domain = normalizeDnsName(domain); if (status != 0) { throw DnsQueryFailedException('DNS query failed', rcode: status); @@ -349,7 +349,9 @@ class DnsResponse { resolver, txt .map((r) => DnsTxtRecord( - name: owner, ttl: r.ttl, bytes: decodeDnsTxtBytes(r.data))) + name: owner, + ttl: r.ttl, + bytes: decodeDnsTxtBytes(r.data, txtFormat))) .toList(), aliases, this); @@ -368,8 +370,22 @@ class DnsResponse { } } -// DNS decimal escapes encode octets; decode UTF-8 only after joining TXT chunks. -List decodeDnsTxtBytes(String value, {int maxPresentationLength = 8192}) { +/// How a JSON DoH resolver writes TXT `data`. Providers differ and a value +/// alone cannot reveal which form it is in, so the format is set per endpoint. +enum DnsTxtFormat { + /// Quoted RFC 1035 character-strings separated by spaces, with `\DDD` + /// decimal and `\X` literal escapes, as Cloudflare returns them. + quoted, + + /// The character-strings concatenated verbatim, without quoting or + /// escaping, as Google returns them. + raw, +} + +/// Decodes JSON TXT `data` in [format] to the record's octets. DNS decimal +/// escapes encode octets, so UTF-8 is decoded only after chunks are joined. +List decodeDnsTxtBytes(String value, DnsTxtFormat format, + {int maxPresentationLength = 8192}) { if (value.length > maxPresentationLength) { throw const DnsResponseTooLargeException('TXT presentation exceeds limit'); } @@ -385,8 +401,9 @@ List decodeDnsTxtBytes(String value, {int maxPresentationLength = 8192}) { i++; } } - if (!value.startsWith('"')) return utf8.encode(value); + if (format == DnsTxtFormat.raw) return utf8.encode(value); final bytes = []; + var strings = 0; var i = 0; while (i < value.length) { while (i < value.length && value[i] == ' ') { @@ -431,6 +448,9 @@ List decodeDnsTxtBytes(String value, {int maxPresentationLength = 8192}) { } } if (!closed) throw malformed; + strings++; } + // A TXT record holds at least one character-string, though it may be "". + if (strings == 0) throw malformed; return bytes; } diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index 2f9a5f3..dcb9e04 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -41,14 +41,20 @@ class DohResolver { final Uri endpoint; final Duration timeout; final int maxResponseBytes; + final DnsTxtFormat txtFormat; // Decoded once so a malformed endpoint fails construction, not each lookup. late final Map> _endpointParameters; DohResolver( {required this.transport, Uri? endpoint, this.timeout = const Duration(seconds: 12), - this.maxResponseBytes = 65536}) - : endpoint = endpoint ?? Uri.https('dns.google', '/resolve') { + this.maxResponseBytes = 65536, + DnsTxtFormat? txtFormat}) + : endpoint = endpoint ??= Uri.https('dns.google', '/resolve'), + txtFormat = txtFormat ?? + _knownTxtFormat(endpoint.host) ?? + (throw ArgumentError.value(endpoint, 'endpoint', + 'Pass txtFormat: JSON TXT encoding differs between providers')) { if (this.endpoint.scheme != 'https' || this.endpoint.host.isEmpty || this.endpoint.userInfo.isNotEmpty || @@ -128,7 +134,7 @@ class DohResolver { final body = jsonDecode(utf8.decode(response.body)); result.complete( DnsResponse.fromJson(body, resolver: endpoint, age: response.age) - .authenticatedTxt(domain)); + .authenticatedTxt(domain, txtFormat: txtFormat)); } on DnsException catch (error, stack) { result.completeError(error, stack); } on FormatException catch (_, stack) { @@ -157,3 +163,18 @@ class DohResolver { () => abort(const DnsCancelledException('DNS query cancelled'))); } } + +DnsTxtFormat? _knownTxtFormat(String host) => switch (host) { + 'dns.google' || + 'dns.google.com' || + '8.8.8.8' || + '8.8.4.4' => + DnsTxtFormat.raw, + 'cloudflare-dns.com' || + 'one.one.one.one' || + '1.1.1.1' || + '1.0.0.1' => + DnsTxtFormat.quoted, + _ when host.endsWith('.cloudflare-dns.com') => DnsTxtFormat.quoted, + _ => null, + }; diff --git a/test/dns_test.dart b/test/dns_test.dart index ef6f47c..7d40dac 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -47,7 +47,8 @@ void main() { rr('pay.example.', 16, 'two'), rr('evil.example.', 16, 'evil') ])); - final result = response.authenticatedTxt('Alice.Example.'); + final result = response.authenticatedTxt('Alice.Example.', + txtFormat: DnsTxtFormat.raw); expect(result.records.map((r) => r.text), ['one', 'two']); expect(result.canonicalName, 'pay.example'); expect(result.aliases, hasLength(1)); @@ -61,14 +62,17 @@ void main() { reply()..remove(field), reply()..[field] = field != 'AD' ]) { - expect(() => decode(body).authenticatedTxt('alice.example'), + expect( + () => decode(body) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), throwsA(isA())); } }); } test('DNS error preserves status without inferring bogus or unsigned', () { expect( - () => decode(reply()..['Status'] = 2).authenticatedTxt('alice.example'), + () => decode(reply()..['Status'] = 2) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), throwsA( isA().having((e) => e.rcode, 'rcode', 2))); }); @@ -116,21 +120,21 @@ void main() { test('rejects mismatched questions $questions', () { expect( () => decode(reply()..['Question'] = questions) - .authenticatedTxt('alice.example'), + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), throwsA(isA())); }); } test('no answers is distinct from DNS failure', () { expect( decode(reply()..remove('Answer')) - .authenticatedTxt('alice.example') + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw) .records, isEmpty); }); test('rejects loops, ambiguous owners and excessive chains', () { expect( () => decode(reply(answers: [rr('alice.example', 5, 'alice.example')])) - .authenticatedTxt('alice.example'), + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), throwsA(isA())); for (final records in [ [rr('alice.example', 5, 'pay.example'), rr('alice.example', 16, 'txt')], @@ -140,8 +144,8 @@ void main() { ] ]) { expect( - () => - decode(reply(answers: records)).authenticatedTxt('alice.example'), + () => decode(reply(answers: records)) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), throwsA(isA())); } final chain = [ @@ -150,7 +154,8 @@ void main() { rr('hop7.example', 16, 'txt') ]; expect( - () => decode(reply(answers: chain)).authenticatedTxt('alice.example'), + () => decode(reply(answers: chain)) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), throwsA(isA())); }); test('rejects invalid TTLs, names, record data, types and response shape', @@ -180,13 +185,17 @@ void main() { }); test('TXT chunks and decimal UTF-8 octets are decoded without loss', () { expect( - utf8.decode( - decodeDnsTxtBytes(r'"oa1:xmr recipient_" "address=valid;"')), + utf8.decode(decodeDnsTxtBytes( + r'"oa1:xmr recipient_" "address=valid;"', DnsTxtFormat.quoted)), 'oa1:xmr recipient_address=valid;'); - expect(utf8.decode(decodeDnsTxtBytes(r'"\195\169\032\""')), 'é "'); - expect(utf8.decode(decodeDnsTxtBytes('"emoji 😀"')), 'emoji 😀'); - expect(decodeDnsTxtBytes(r'"\255"'), [255]); - expect(utf8.decode(decodeDnsTxtBytes('"\\😀"')), '😀'); + expect( + utf8.decode( + decodeDnsTxtBytes(r'"\195\169\032\""', DnsTxtFormat.quoted)), + 'é "'); + expect(utf8.decode(decodeDnsTxtBytes('"emoji 😀"', DnsTxtFormat.quoted)), + 'emoji 😀'); + expect(decodeDnsTxtBytes(r'"\255"', DnsTxtFormat.quoted), [255]); + expect(utf8.decode(decodeDnsTxtBytes('"\\😀"', DnsTxtFormat.quoted)), '😀'); expect(() => DnsTxtRecord(name: 'x', ttl: 1, bytes: [255]).text, throwsA(isA())); }); @@ -223,7 +232,7 @@ void main() { final result = decode(reply(answers: [ rr('alice.example', 5, 'pay.example')..['TTL'] = raw, rr('pay.example', 16, 'x')..['TTL'] = raw, - ])).authenticatedTxt('alice.example'); + ])).authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw); expect(result.records.single.ttl, ttl); expect(result.aliases.single.ttl, ttl); final issued = DnsValidationIssuer('test').issueTxt( @@ -241,6 +250,59 @@ void main() { expect(issued.aliases.single.ttl, ttl); } }); + test('raw TXT is taken verbatim and quoted TXT must be quoted', () { + for (final value in ['"hello"', r'\065 x', '"unmatched']) { + expect(utf8.decode(decodeDnsTxtBytes(value, DnsTxtFormat.raw)), value); + } + expect(() => decodeDnsTxtBytes('lone \ud800', DnsTxtFormat.raw), + throwsA(isA())); + expect(decodeDnsTxtBytes('""', DnsTxtFormat.quoted), isEmpty); + expect(decodeDnsTxtBytes(' "" "" ', DnsTxtFormat.quoted), isEmpty); + for (final value in ['hello', 'a "b"', '', ' ']) { + expect(() => decodeDnsTxtBytes(value, DnsTxtFormat.quoted), + throwsA(isA())); + } + }); + test('known resolvers get their TXT format; others must choose', () { + DnsTxtFormat formatOf(String url) => + DohResolver(transport: FakeTransport(), endpoint: Uri.parse(url)) + .txtFormat; + expect(DohResolver(transport: FakeTransport()).txtFormat, DnsTxtFormat.raw); + expect(formatOf('https://dns.google/resolve'), DnsTxtFormat.raw); + for (final host in [ + 'cloudflare-dns.com', + 'security.cloudflare-dns.com', + 'one.one.one.one', + '1.1.1.1' + ]) { + expect(formatOf('https://$host/dns-query'), DnsTxtFormat.quoted); + } + expect(() => formatOf('https://resolver.example/resolve'), + throwsArgumentError); + expect( + DohResolver( + transport: FakeTransport(), + endpoint: Uri.parse('https://dns.google/resolve'), + txtFormat: DnsTxtFormat.quoted) + .txtFormat, + DnsTxtFormat.quoted); + }); + for (final (endpoint, data, text) in [ + ('https://dns.google/resolve', '"hello"', '"hello"'), + ('https://cloudflare-dns.com/dns-query', r'"hel" "lo\033"', 'hello!'), + ]) { + test('$endpoint TXT $data decodes to $text', () async { + final transport = FakeTransport(); + final result = + DohResolver(transport: transport, endpoint: Uri.parse(endpoint)) + .lookupTxt('alice.example'); + transport.completer.complete(DohHttpResponse( + 200, + utf8.encode( + jsonEncode(reply(answers: [rr('alice.example', 16, data)]))))); + expect((await result).records.single.text, text); + }); + } test('malformed and overlong TXT cannot turn into recipient data', () { for (final s in [ '"unterminated', @@ -255,10 +317,10 @@ void main() { '"\\\ud800"', '"\ud800 x"', ]) { - expect(() => decodeDnsTxtBytes(s), + expect(() => decodeDnsTxtBytes(s, DnsTxtFormat.quoted), throwsA(isA())); } - expect(() => decodeDnsTxtBytes('a' * 8193), + expect(() => decodeDnsTxtBytes('a' * 8193, DnsTxtFormat.quoted), throwsA(isA())); }); test('DNS names allow service labels without permitting alias syntax', () { @@ -297,6 +359,7 @@ void main() { test('repeated endpoint parameters survive; resolver keys override', () { final transport = FakeTransport(); DohResolver( + txtFormat: DnsTxtFormat.raw, transport: transport, endpoint: Uri.parse('https://dns.example/resolve' '?tag=first&tag=second&name=evil&type=A&type=AAAA')) @@ -312,6 +375,7 @@ void main() { for (final name in ['a.example', 'abcd.example']) { final transport = FakeTransport(); DohResolver( + txtFormat: DnsTxtFormat.raw, transport: transport, endpoint: Uri.parse('https://dns.example/resolve?ct=x')) .startTxtLookup(name); @@ -338,7 +402,9 @@ void main() { ]) { expect( () => DohResolver( - transport: FakeTransport(), endpoint: Uri.parse(endpoint)), + txtFormat: DnsTxtFormat.raw, + transport: FakeTransport(), + endpoint: Uri.parse(endpoint)), throwsArgumentError); } }); diff --git a/test/io_connection_reuse_test.dart b/test/io_connection_reuse_test.dart index 43e2dbd..d7b826a 100644 --- a/test/io_connection_reuse_test.dart +++ b/test/io_connection_reuse_test.dart @@ -339,6 +339,7 @@ void main() { }); final t = transport(null); final resolver = DohResolver( + txtFormat: DnsTxtFormat.raw, transport: t, endpoint: Uri.parse('https://localhost:${server.port}/resolve')); expect( diff --git a/test/io_transport_test.dart b/test/io_transport_test.dart index 0e49733..4811378 100644 --- a/test/io_transport_test.dart +++ b/test/io_transport_test.dart @@ -46,7 +46,10 @@ void main() { ? SocksProxy(InternetAddress.loopbackIPv4, proxy.port) : null, proxyDestination: proxied ? InternetAddress('8.8.8.8') : null); - final resolver = DohResolver(endpoint: endpoint(), transport: transport); + final resolver = DohResolver( + txtFormat: DnsTxtFormat.raw, + endpoint: endpoint(), + transport: transport); final result = await resolver.lookupTxt('alice.example'); expect(result.records.single.text, 'oa1:xmr recipient_address=valid;'); expect(result.resolver, endpoint()); @@ -69,6 +72,7 @@ void main() { await proxy.start(); addTearDown(proxy.close); final resolver = DohResolver( + txtFormat: DnsTxtFormat.raw, endpoint: endpoint(), transport: IoDohTransport( securityContext: trust, @@ -82,7 +86,10 @@ void main() { test('HTTPS identity must be trusted', () async { server.listen((r) => r.response.close(), onError: (Object _) {}); await expectLater( - DohResolver(endpoint: endpoint(), transport: IoDohTransport()) + DohResolver( + txtFormat: DnsTxtFormat.raw, + endpoint: endpoint(), + transport: IoDohTransport()) .lookupTxt('alice.example'), throwsA(isA())); }); @@ -97,6 +104,7 @@ void main() { }); await expectLater( DohResolver( + txtFormat: DnsTxtFormat.raw, endpoint: endpoint(), transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'), @@ -111,6 +119,7 @@ void main() { }); await expectLater( DohResolver( + txtFormat: DnsTxtFormat.raw, endpoint: endpoint(), maxResponseBytes: 128, transport: IoDohTransport(securityContext: trust)) @@ -126,6 +135,7 @@ void main() { }); await expectLater( DohResolver( + txtFormat: DnsTxtFormat.raw, endpoint: endpoint(), transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'), @@ -145,6 +155,7 @@ void main() { await proxy.start(); addTearDown(proxy.close); final resolver = DohResolver( + txtFormat: DnsTxtFormat.raw, endpoint: endpoint(), timeout: const Duration(milliseconds: 300), transport: IoDohTransport( @@ -188,6 +199,7 @@ void main() { r.response.close(); }); final result = await DohResolver( + txtFormat: DnsTxtFormat.raw, endpoint: endpoint(), transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'); @@ -255,6 +267,7 @@ void main() { }); await expectLater( DohResolver( + txtFormat: DnsTxtFormat.raw, endpoint: endpoint(), transport: IoDohTransport(securityContext: trust)) .lookupTxt('alice.example'), diff --git a/test/live_test.dart b/test/live_test.dart index 8f2b3cd..a544763 100644 --- a/test/live_test.dart +++ b/test/live_test.dart @@ -47,4 +47,15 @@ void main() { expect(result.records, isNotEmpty); }, skip: skip); } + test('Google raw and Cloudflare quoted TXT decode identically', () async { + Future> texts(Uri endpoint) async => { + for (final record in (await DohResolver( + transport: IoDohTransport(), endpoint: endpoint) + .lookupTxt('getmonero.org')) + .records) + record.text + }; + expect(await texts(Uri.https('dns.google', '/resolve')), + await texts(Uri.https('cloudflare-dns.com', '/dns-query'))); + }, skip: skip); } diff --git a/test/local_evidence_test.dart b/test/local_evidence_test.dart index 0e437f4..d715c64 100644 --- a/test/local_evidence_test.dart +++ b/test/local_evidence_test.dart @@ -98,7 +98,8 @@ void main() { {'name': 'bob.example.', 'type': 16, 'TTL': 60, 'data': 'A'} ], }, resolver: endpoint); - final result = response.authenticatedTxt('bob.example'); + final result = + response.authenticatedTxt('bob.example', txtFormat: DnsTxtFormat.raw); expect(result.authentication, DnsAuthentication.resolverAsserted); expect(result.validator, isNull); }); From 9e18e97b6663ecb2f85fc3bfabad1c594a7b7541 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:31:57 -0600 Subject: [PATCH 08/22] fix(wire): register uncompressed names in newer RDATA types Compression pointers may only target names the parser has walked, but it skipped the RDATA of types it did not know, so a later name pointing into an HTTPS TargetName or an IPSECKEY gateway was rejected while the uncompressed equivalent was accepted. The parser now walks the embedded names of NSAP-PTR, IPSECKEY, HIP, TALINK, SVCB and HTTPS, DSYNC, LP, TKEY, TSIG and AMTRELAY. Those names must themselves be uncompressed, as RFC 3597 requires for newer types, and the existing backward-only pointer rules are unchanged. Co-Authored-By: Claude Opus 5.5 --- lib/src/wire.dart | 56 +++++++++++++++++++++++++++++++++- test/wire_test.dart | 73 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/lib/src/wire.dart b/lib/src/wire.dart index 819ddf2..db55443 100644 --- a/lib/src/wire.dart +++ b/lib/src/wire.dart @@ -228,6 +228,54 @@ class _WireMessage { if (_offset >= end) throw _malformed; case 47: _name(limit: end, compressed: false); + // Newer types forbid compressing their own names, but a later name may + // still point into them, so each one is walked and registered. + case 55: + _skip(4, end); + final hitLength = data[_offset - 4]; + final keyLength = _u16(_offset - 2); + _skip(hitLength + keyLength, end); + while (_offset < end) { + _name(limit: end, compressed: false); + } + case 58: + _name(limit: end, compressed: false); + _name(limit: end, compressed: false); + case 64 || 65 || 107: + _skip(2, end); + _name(limit: end, compressed: false); + case 249 || 250: + _name(limit: end, compressed: false); + case 23: + _name(limit: end, compressed: false); + case 66: + _skip(5, end); + _name(limit: end, compressed: false); + case 45: + // Precedence, gateway type and algorithm, then the gateway. + _skip(3, end); + switch (data[_offset - 2]) { + case 1: + _skip(4, end); + case 2: + _skip(16, end); + case 3: + _name(limit: end, compressed: false); + } + case 260: + _skip(2, end); + switch (data[_offset - 1] & 0x7f) { + case 0: + break; + case 1: + _skip(4, end); + case 2: + _skip(16, end); + case 3: + _name(limit: end, compressed: false); + default: + _offset = end; + } } if (_offset > end || ({ @@ -250,7 +298,13 @@ class _WireMessage { 35, 36, 38, - 39 + 23, + 39, + 55, + 58, + 66, + 107, + 260 }.contains(type) && _offset != end)) { throw _malformed; diff --git a/test/wire_test.dart b/test/wire_test.dart index 977b28f..c3a644c 100644 --- a/test/wire_test.dart +++ b/test/wire_test.dart @@ -67,6 +67,79 @@ void main() { } }); + test('registers uncompressed names embedded in newer RDATA types', () { + final first = wireName('first.example'); + final second = wireName('second.example'); + final cases = <(int, List, List)>[ + // SVCB and HTTPS: priority, TargetName, then SvcParams. + (64, [0, 1, ...first], [2]), + (65, [0, 1, ...first, 0, 1, 0, 2, 0x68, 0x32], [2]), + // HIP: HIT and public key, then rendezvous servers. + ( + 55, + [2, 1, 0, 1, 0xaa, 0xbb, 0xcc, ...first, ...second], + [7, 7 + first.length] + ), + (58, [...first, ...second], [0, first.length]), + (107, [0, 1, ...first], [2]), + (249, [...first, ...List.filled(16, 0)], [0]), + (250, [...first, ...List.filled(16, 0)], [0]), + // AMTRELAY with a domain-name relay (type 3). + (260, [0, 3, ...first], [2]), + (23, first, [0]), + // IPSECKEY with a domain-name gateway (type 3), then the public key. + (45, [10, 3, 2, ...first, 1, 2, 3], [3]), + // DSYNC: RRtype, scheme and port, then the target. + (66, [0, 59, 1, 0x14, 0xeb, ...first], [5]), + ]; + for (final (type, rdata, offsets) in cases) { + validateDnsWireResponse( + query, wireReply(answers: [wireRecord(pointer, type, rdata)])); + for (final offset in offsets) { + validateDnsWireResponse( + query, _ownerPointerIntoRdata(type, rdata, offset)); + } + } + }); + + test('newer RDATA names must not be compressed or overrun', () { + for (final (type, rdata) in <(int, List)>[ + (64, [0, 1, ...pointer]), + (65, [0, 1, ...pointer]), + (55, [1, 1, 0, 0, 0xaa, ...pointer]), + (55, [4, 1, 0, 0, 0xaa]), + (58, [...pointer, 0]), + (58, [0]), + (107, [0, 1, 0, 0]), + (250, [...pointer]), + (260, [0, 3, ...pointer]), + (260, [0, 1, 1, 2, 3]), + (260, [0, 0, 1]), + (23, pointer), + (45, [10, 3, 2, ...pointer]), + (45, [10, 1, 2, 1, 2, 3]), + (66, [0, 59, 1, 0x14, 0xeb, ...pointer]), + (66, [0, 59, 1, 0x14, 0xeb, 0, 1]), + ]) { + expect( + () => validateDnsWireResponse( + query, wireReply(answers: [wireRecord(pointer, type, rdata)])), + throwsA(isA()), + reason: 'type $type rdata $rdata'); + } + for (final (type, rdata) in <(int, List)>[ + (260, [0, 1, 1, 2, 3, 4]), + (260, [0, 2, ...List.filled(16, 0)]), + (260, [0, 0]), + (45, [10, 0, 2, 1, 2, 3]), + (45, [10, 1, 2, 1, 2, 3, 4, 9]), + (45, [10, 2, 0, ...List.filled(16, 0)]), + ]) { + validateDnsWireResponse( + query, wireReply(answers: [wireRecord(pointer, type, rdata)])); + } + }); + test('accepts legacy RDATA compression required for compatibility', () { for (final (type, rdata) in <(int, List)>[ (17, [...pointer, ...pointer]), From 008acda9e924b75b6bdad8ed373663ab4e099ebf Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:32:15 -0600 Subject: [PATCH 09/22] feat(wire): add effectiveDnsTtl and adjustDnsWireTtls Wire bodies are returned as received, leaving every caller to apply the HTTP Age and the RFC 2181 high-bit rule itself. effectiveDnsTtl applies both to one TTL and now backs the JSON path too, so the rule lives in one place. adjustDnsWireTtls returns a copy of a wire response with every record TTL adjusted, skipping OPT, whose TTL field holds EDNS flags. RRSIG validation is unaffected because it uses the Original TTL, but SIG(0) and TSIG transaction signatures cover the whole message, so they must be verified before adjusting. Co-Authored-By: Claude Opus 5.5 --- lib/doh_resolver.dart | 3 ++- lib/src/dns.dart | 22 ++++++++++++---- lib/src/wire.dart | 23 +++++++++++++++++ test/wire_test.dart | 59 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/lib/doh_resolver.dart b/lib/doh_resolver.dart index 1395940..fa6e2c6 100644 --- a/lib/doh_resolver.dart +++ b/lib/doh_resolver.dart @@ -2,4 +2,5 @@ library; export 'src/dns.dart'; export 'src/resolver.dart'; -export 'src/wire.dart' show validateDnsWireResponse, padDnsWireQuery; +export 'src/wire.dart' + show adjustDnsWireTtls, padDnsWireQuery, validateDnsWireResponse; diff --git a/lib/src/dns.dart b/lib/src/dns.dart index d5c9816..a8a5244 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -75,8 +75,14 @@ final class DnsTimeoutException extends DnsException { final _label = RegExp(r'^[a-z0-9_-]+$'); -// RFC 2181 §8: a received TTL with the most significant bit set means zero. -int _ttl(int ttl) => ttl > 0x7fffffff ? 0 : ttl; +/// The remaining lifetime of a received DNS [ttl], in seconds: zero when its +/// most significant bit is set (RFC 2181 §8), reduced by the HTTP cache [age] +/// (RFC 8484 §5.1), and never negative. +int effectiveDnsTtl(int ttl, {Duration age = Duration.zero}) { + if (ttl < 0 || ttl > 0xffffffff) throw ArgumentError.value(ttl, 'ttl'); + if (age < Duration.zero) throw ArgumentError.value(age, 'age'); + return max(0, (ttl > 0x7fffffff ? 0 : ttl) - age.inSeconds); +} String normalizeDnsName(String name) { if (name.codeUnits.any((c) => c > 127)) { @@ -179,7 +185,10 @@ final class DnsValidationIssuer { 'Invalid validated CNAME chain'); } chain.add(DnsRecord( - name: owner, type: 5, ttl: _ttl(alias.ttl), data: alias.data)); + name: owner, + type: 5, + ttl: effectiveDnsTtl(alias.ttl), + data: alias.data)); owner = normalizeDnsName(alias.data); if (!seen.add(owner)) { throw const DnsAliasLoopException('CNAME loop'); @@ -195,7 +204,10 @@ final class DnsValidationIssuer { resolver, [ for (final r in records) - DnsTxtRecord(name: canonicalName, ttl: _ttl(r.ttl), bytes: r.bytes) + DnsTxtRecord( + name: canonicalName, + ttl: effectiveDnsTtl(r.ttl), + bytes: r.bytes) ], chain, null, @@ -301,7 +313,7 @@ class DnsResponse { answers.add(DnsRecord( name: name(a['name']), type: a['type'] as int, - ttl: max(0, _ttl(a['TTL'] as int) - age.inSeconds), + ttl: effectiveDnsTtl(a['TTL'] as int, age: age), data: a['data'] as String)); } return DnsResponse._( diff --git a/lib/src/wire.dart b/lib/src/wire.dart index db55443..2be1fe9 100644 --- a/lib/src/wire.dart +++ b/lib/src/wire.dart @@ -30,6 +30,27 @@ void validateDnsWireResponse(List query, List response) { } } +/// A copy of wire [response] with every record TTL replaced by its +/// [effectiveDnsTtl] after [age], such as `DohHttpResponse.age`. OPT is left +/// alone: its TTL field carries EDNS flags. RRSIG validation is unaffected +/// because it uses the signature's Original TTL, but SIG(0) and TSIG +/// transaction signatures cover the whole message, so verify them first. +List adjustDnsWireTtls(List response, {required Duration age}) { + if (age < Duration.zero) throw ArgumentError.value(age, 'age'); + final message = _WireMessage.parse(response); + final adjusted = Uint8List.fromList(response); + for (final at in message.ttlOffsets) { + final ttl = message._u16(at) << 16 | message._u16(at + 2); + final value = effectiveDnsTtl(ttl, age: age); + adjusted + ..[at] = value >> 24 + ..[at + 1] = value >> 16 & 0xff + ..[at + 2] = value >> 8 & 0xff + ..[at + 3] = value & 0xff; + } + return List.unmodifiable(adjusted); +} + List padDnsWireQuery(List query, {int blockSize = 128}) { if (blockSize < 1 || blockSize > 512) { throw ArgumentError.value(blockSize, 'blockSize'); @@ -106,6 +127,7 @@ class _WireMessage { bool optIsLast = false; bool optHasPadding = false; bool isSigned = false; + final ttlOffsets = []; int _offset = 12; _WireMessage._(this.data); @@ -157,6 +179,7 @@ class _WireMessage { final start = _offset + 10; final end = start + rdlength; if (end > data.length) throw _malformed; + if (type != 41) ttlOffsets.add(_offset + 4); // SIG(0) and TSIG sign the header, including the ID. if (additional && (type == 24 || type == 250)) isSigned = true; if (type == 41) { diff --git a/test/wire_test.dart b/test/wire_test.dart index c3a644c..fe3c222 100644 --- a/test/wire_test.dart +++ b/test/wire_test.dart @@ -289,6 +289,65 @@ void main() { } }); + group('wire TTL adjustment', () { + int ttlAt(List message, int at) => + message[at] << 24 | + message[at + 1] << 16 | + message[at + 2] << 8 | + message[at + 3]; + + test('ages record TTLs and leaves OPT and the input untouched', () { + final txt = wireRecord(pointer, 16, txtRdata('x')); + final reply = wireReply(answers: [txt, txt], additional: [txt]); + final original = [...reply]; + final head = wireReply().length - wireOpt().length; + final ttls = [ + head + 6, + head + txt.length + 6, + head + 2 * txt.length + 6, + ]; + final aged = adjustDnsWireTtls(reply, age: const Duration(seconds: 25)); + expect([for (final at in ttls) ttlAt(aged, at)], [35, 35, 35]); + expect(aged.sublist(aged.length - wireOpt().length), wireOpt()); + expect(reply, original); + validateDnsWireResponse(query, aged); + expect( + ttlAt(adjustDnsWireTtls(reply, age: const Duration(hours: 1)), + ttls.first), + 0); + }); + + test('treats high-bit TTLs as zero', () { + final reply = + wireReply(answers: [wireRecord(pointer, 16, txtRdata('x'))]); + final at = wireReply().length - wireOpt().length + 6; + reply[at] = 0x80; + expect(ttlAt(adjustDnsWireTtls(reply, age: Duration.zero), at), 0); + }); + + test('rejects malformed messages and negative ages', () { + expect(() => adjustDnsWireTtls([0, 1, 2], age: Duration.zero), + throwsA(isA())); + final empty = wireHeader(flags: 0x8180, qd: 0); + expect(() => adjustDnsWireTtls(empty, age: const Duration(seconds: -1)), + throwsArgumentError, + reason: 'even with no TTL to adjust'); + }); + }); + + test('effectiveDnsTtl applies RFC 2181 and the HTTP age', () { + expect(effectiveDnsTtl(600), 600); + expect(effectiveDnsTtl(600, age: const Duration(seconds: 250)), 350); + expect(effectiveDnsTtl(60, age: const Duration(seconds: 61)), 0); + expect(effectiveDnsTtl(0x7fffffff), 0x7fffffff); + expect(effectiveDnsTtl(0x80000000), 0); + for (final ttl in [-1, 0x100000000]) { + expect(() => effectiveDnsTtl(ttl), throwsArgumentError); + } + expect(() => effectiveDnsTtl(1, age: const Duration(seconds: -1)), + throwsArgumentError); + }); + group('query padding', () { test('pads a final OPT record to a 128-octet block', () { final padded = padDnsWireQuery(query); From 8694c141b2e65b7cc195c6a39242869f4238ddc8 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:32:42 -0600 Subject: [PATCH 10/22] build: check on every platform with a Dart script and an OS matrix tool/check.sh needed a POSIX shell, so it failed from cmd or PowerShell. tool/check.dart runs the same steps (pub get, format, analyze, test, publish dry-run) through the running Dart VM, so no PATH or .bat lookup is involved; it streams their output, works from any directory, and exits with the first failing step's code. The example now runs from a test, so dart test alone catches a broken example. CI only ran on Ubuntu, but the transport depends on dart:io sockets, TLS and timing. It now runs the checker on Linux, macOS and Windows for the minimum and stable SDKs, without fail-fast, and .gitattributes pins LF endings so Windows checkouts do not trip dart format or byte-exact tests. lints moves to ^5.0.0, the newest release supporting Dart 3.5. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 ++ .github/workflows/test.yml | 7 +++++-- README.md | 6 ++++++ pubspec.yaml | 2 +- test/example_test.dart | 13 +++++++++++++ tool/check.dart | 35 +++++++++++++++++++++++++++++++++++ tool/check.sh | 9 --------- 7 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 .gitattributes create mode 100644 test/example_test.dart create mode 100644 tool/check.dart delete mode 100755 tool/check.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9cbbe76 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep LF on every platform so dart format and byte-exact tests agree. +* text=auto eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49414fd..aba94d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,13 +4,16 @@ permissions: contents: read jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: + # The transport depends on platform sockets and TLS; report every OS. + fail-fast: false matrix: + os: [ubuntu-latest, macos-latest, windows-latest] sdk: ['3.5.0', stable] steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d with: sdk: ${{ matrix.sdk }} - - run: bash tool/check.sh + - run: dart tool/check.dart diff --git a/README.md b/README.md index 5b99b25..cba015b 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,9 @@ RFC 8484 requires DNS TTLs to be reduced by the HTTP `Age` of a response. `DohResolver` applies this to JSON answers, flooring TTLs at zero. Wire-format bodies are returned unmodified; callers must subtract `DohHttpResponse.age` from any TTL they decode. + +## Development + +Run every pre-merge check (format, analyze, tests, publish dry-run) on any +platform with `dart tool/check.dart`. Live resolver tests are skipped unless +`DOH_LIVE_TESTS=1` is set. diff --git a/pubspec.yaml b/pubspec.yaml index d6314ee..bbf5d11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,6 @@ environment: dependencies: socks_socket: ^2.0.0 dev_dependencies: - lints: ^4.0.0 + lints: ^5.0.0 pointycastle: ^4.0.0 test: ^1.25.0 diff --git a/test/example_test.dart b/test/example_test.dart new file mode 100644 index 0000000..1f87c85 --- /dev/null +++ b/test/example_test.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + test('the example compiles and prints usage without arguments', () async { + final result = await Process.run( + Platform.resolvedExecutable, ['example/doh_resolver_example.dart']); + expect(result.stderr, isEmpty); + expect(result.exitCode, 0); + expect(result.stdout, startsWith('Usage: dart run ')); + }, timeout: const Timeout(Duration(minutes: 2))); +} diff --git a/tool/check.dart b/tool/check.dart new file mode 100644 index 0000000..e964cde --- /dev/null +++ b/tool/check.dart @@ -0,0 +1,35 @@ +// Runs every pre-merge check in order, stopping at the first failure. +// Usage, from anywhere: dart tool/check.dart +import 'dart:io'; + +const _steps = [ + ['pub', 'get', '--no-example'], + [ + 'format', + '--output=none', + '--set-exit-if-changed', + 'lib', + 'test', + 'tool', + 'example' + ], + ['analyze', 'lib', 'test', 'tool', 'example'], + ['test', '--reporter', 'expanded'], + ['pub', 'publish', '--dry-run'], +]; + +Future main() async { + // The running VM, so this works without dart on PATH and on Windows. + final dart = Platform.resolvedExecutable; + final root = File.fromUri(Platform.script).parent.parent.path; + for (final step in _steps) { + stdout.writeln('\$ dart ${step.join(' ')}'); + final process = await Process.start(dart, step, + workingDirectory: root, mode: ProcessStartMode.inheritStdio); + final code = await process.exitCode; + if (code != 0) { + stderr.writeln('Check failed: dart ${step.join(' ')} (exit $code)'); + exit(code); + } + } +} diff --git a/tool/check.sh b/tool/check.sh deleted file mode 100755 index f5504bb..0000000 --- a/tool/check.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/.." -dart pub get --no-example -dart format --output=none --set-exit-if-changed lib test tool example -dart analyze lib test tool example -dart test --reporter expanded -dart --packages=.dart_tool/package_config.json example/doh_resolver_example.dart -dart pub publish --dry-run From 90020ae118765e5518ba9f425789541ff3123ff8 Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 09:33:02 -0600 Subject: [PATCH 11/22] docs: document the public API, trust model and contracts Adds dartdoc to every public declaration, prioritising trust, cancellation, limits and lifecycle, and validated with dart doc. The README is reorganised around the trust model (including checking a DnsValidator by identity, since anyone can construct an issuer), per-provider JSON behaviour, transport limits, deadlines, connection reuse and shutdown, the error and cancellation contract, and TTL handling. The unreleased 0.0.1 changelog entry now describes the package as it stands. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 +++++- README.md | 162 ++++++++++++++++++++++++++------------ lib/doh_resolver.dart | 3 + lib/doh_resolver_io.dart | 2 + lib/src/dns.dart | 52 +++++++++++- lib/src/io_transport.dart | 30 ++++++- lib/src/resolver.dart | 46 ++++++++++- lib/src/wire.dart | 7 ++ 8 files changed, 269 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5f686..c07ce7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,23 @@ ## 0.0.1 - Initial release. -- Bounded JSON and wire-format DNS over HTTPS, including authenticated TXT resolution over direct or SOCKS HTTPS. -- Local-validation evidence issued by `DnsValidationIssuer.issueTxt`, carrying the issuing `DnsValidator`. -- Connection reuse within `IoDohTransport`, with exclusive per-request lanes, `close()`, `idleTimeout` and `reuseConnections`. -- SOCKS transport through `socks_socket` ^2.0.0. +- `DohResolver`: bounded JSON TXT lookups that accept only resolver-asserted, + untruncated, checking-enabled answers to the exact question, following CNAME + chains strictly. JSON TXT data is decoded per provider through `DnsTxtFormat`, + defaulting for Google and Cloudflare hosts. +- Local-validation evidence issued by `DnsValidationIssuer.issueTxt`, carrying + the issuing `DnsValidator` for identity checks. +- Wire-format helpers: `validateDnsWireResponse` for strict framing and + query/response binding, `padDnsWireQuery` for RFC 8467 padding, and + `adjustDnsWireTtls` for cache-ready TTLs. +- TTLs honour RFC 2181 (high-bit TTLs are zero) and RFC 8484 HTTP `Age`, also + exposed as `effectiveDnsTtl`. +- `IoDohTransport`: direct or SOCKS5 HTTPS through `socks_socket` ^2.0.0, with + certificate verification, no redirects or proxy fallback, bounded bodies, + deadlines, concurrency and queueing, and `close()`. +- Wire connection reuse per origin with transport-owned transaction IDs, so ID 0 + queries reuse connections while stale replies stay detectable. Signed queries, + and exchanges started with `preserveMessage`, keep their exact bytes. Reuse is + off by default when a proxy is set. +- Every failure is a subtype of the sealed `DnsException`, delivered through + `DnsOperation.result`; starting an operation never throws. diff --git a/README.md b/README.md index cba015b..2567507 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,121 @@ # doh_resolver -Bounded JSON and wire-format DNS-over-HTTPS with direct and SOCKS transports. +Bounded DNS over HTTPS for Dart: JSON TXT resolution with explicit +authentication evidence, RFC 8484 wire-format helpers, and a `dart:io` +transport for direct or SOCKS5 HTTPS. + +```dart +import 'package:doh_resolver/doh_resolver_io.dart'; + +final transport = IoDohTransport(); +try { + final result = await DohResolver(transport: transport).lookupTxt('example.com'); + for (final record in result.records) { + print('${record.ttl}s ${record.text}'); + } +} on DnsQueryFailedException catch (error) { + print('DNS rcode ${error.rcode}'); +} on DnsException catch (error) { + print('Rejected: $error'); +} finally { + await transport.close(); +} +``` ## Trust model -`DohResolver.lookupTxt` accepts the HTTPS resolver's AD assertion only when the -reply is complete, checking is enabled, and its single TXT question matches the -requested name. The resulting `DnsAuthentication.resolverAsserted` evidence is -not local DNSSEC validation. Use a local validator such as `dnssec_resolver` -when the resolver itself must not choose the answer. - -`validateDnsWireResponse` provides strict message framing and request/response -binding; it does not validate DNSSEC signatures or make answer data trusted. - -## Transport lifecycle - -`IoDohTransport` requires HTTPS, verifies the certificate for the endpoint -hostname, rejects redirects, bounds response bodies and deadlines, and never -falls back from an explicitly configured SOCKS proxy to a direct connection. -SOCKS hostnames are resolved by the proxy unless the caller explicitly supplies -a numeric `proxyDestination`. - -Call `close()` when the transport is no longer needed. JSON connections are not -reused because JSON has no transaction identifier. Wire connections are reused -only for the same HTTPS origin. The transport gives each exchange a fresh -random transaction ID on the wire, unique on its connection, and restores the -caller's ID in the returned body, so callers may send ID 0 as RFC 8484 -recommends. Queries signed with SIG(0) or TSIG are sent byte-for-byte with -their own ID, because the signature covers the header; that ID is likewise -never repeated on a connection. Reuse defaults to off when a `proxy` is set, because -queries sharing a connection can be linked by the resolver; pass -`reuseConnections: true` to opt in. Malformed, cancelled, timed-out, or failed exchanges retire the -connection. `maxConcurrentRequests` and `maxQueuedRequests` bound admitted -work; a full queue fails new operations with `DnsTransportException`. - -## Errors - -Every failure is a subtype of the sealed `DnsException`, so callers can catch -a specific case or switch over all of them exhaustively. `DnsQueryFailedException` -carries the DNS `rcode`; `DnsTransportException` carries `httpStatus` when the -resolver returned a non-200 response. - -Starting an operation never throws. Invalid names, URIs and wire queries, -a closed transport, and a full queue all complete `DnsOperation.result` with -a `DnsException`. `ArgumentError` is reserved for invalid constructor -configuration. - -## Cache age - -RFC 8484 requires DNS TTLs to be reduced by the HTTP `Age` of a response. -`DohResolver` applies this to JSON answers, flooring TTLs at zero. Wire-format -bodies are returned unmodified; callers must subtract `DohHttpResponse.age` -from any TTL they decode. +`DohResolver` accepts an answer only when the resolver asserts it is +DNSSEC-authenticated: the AD flag is set, checking is enabled, the reply is not +truncated, and its single TXT question matches the requested name. The result's +`DnsAuthentication.resolverAsserted` evidence means you trust that resolver; it +is not local DNSSEC validation. Use a local validator, such as +`dnssec_resolver`, when the resolver itself must not choose the answer. + +A local validator reports its results through a `DnsValidationIssuer`. Anyone +can construct an issuer, so `DnsAuthentication.locallyValidated` and the +validator's name prove nothing on their own. Keep your issuer private and check +its identity: + +```dart +bool trusted(AuthenticatedTxtResult result) => + identical(result.validator, myIssuer.validator); +``` + +`validateDnsWireResponse` checks message framing and that a response answers +its query; it does not validate DNSSEC signatures or make answer data trusted. + +## JSON providers + +DoH JSON APIs are not standardized, and providers encode TXT data differently: +Google returns the text verbatim, Cloudflare returns quoted character-strings +with escapes. A value alone cannot reveal which form it is in, so +`DohResolver.txtFormat` is chosen per endpoint. Google and Cloudflare hosts get +the right `DnsTxtFormat` by default; any other endpoint must pass `txtFormat`. + +Lookups send `edns_client_subnet=0.0.0.0/0`, which asks providers that +implement it, such as Google, not to forward client subnet data. Cloudflare +does not send client subnets by default. The query URL is padded to a multiple +of 128 characters to hide the name's length. Endpoint query parameters are +kept, including repeated ones, except those the resolver sets itself. + +## Transport + +`IoDohTransport` requires HTTPS, verifies the endpoint certificate, never +follows redirects, bounds response bodies and deadlines, and never falls back +from a configured SOCKS proxy to a direct connection. The proxy resolves +endpoint host names unless `proxyDestination` supplies an address; that +override applies to every endpoint used with the transport, while TLS still +verifies the requested host name. + +- **Limits.** `maxConcurrentRequests` operations run at once and + `maxQueuedRequests` wait; a full queue fails new operations with + `DnsTransportException`. +- **Deadlines.** `requestTimeout` covers each operation, including time spent + queued. `DohResolver.timeout` covers a whole lookup. Whichever is earlier + applies. Connection timeouts are reported as `DnsTransportException`; only an + operation's own deadline is `DnsTimeoutException`. +- **Connection reuse.** JSON requests never share connections. Wire queries to + the same origin do, unless a `proxy` is set: lookups sharing a connection can + be linked by the resolver, so proxied transports reuse only with + `reuseConnections: true`. The transport gives each exchange a random ID that + is unique on its connection and restores the caller's ID in the response, so + callers may send ID 0 as RFC 8484 recommends. SIG(0)- and TSIG-signed queries + are sent byte-for-byte, because their signatures cover the header. Pass + `preserveMessage: true` to `startWire` when you expect a signed response to + an unsigned query: a SIG(0) response covers the request as sent, so the query + is then sent unpadded with its own ID and the response returned untouched. + Any malformed, cancelled, timed-out or failed exchange retires its + connection. +- **Shutdown.** `close()` fails queued and running operations with + `DnsCancelledException` and begins closing connections; socket teardown + finishes asynchronously. +- **HTTP/1.1 only.** Dart's `HttpClient` does not speak HTTP/2, so concurrent + operations use separate connections. + +## Errors and cancellation + +Every failure is a subtype of the sealed `DnsException`, so you can catch one +case, all CNAME failures through `DnsAliasException`, or switch over every case +exhaustively. `DnsQueryFailedException` carries the DNS `rcode`, and +`DnsTransportException` carries `httpStatus` for a non-200 response. + +Starting an operation never throws: invalid names, URIs and wire queries, a +closed transport and a full queue all complete `DnsOperation.result` with a +`DnsException`. `ArgumentError` is reserved for invalid constructor arguments. + +`DnsOperation.cancel` fails an unfinished operation with +`DnsCancelledException`. It does not consume that error: like any `Future`, a +result nobody listens to reports its error as uncaught, so attach a handler or +call `.ignore()` on results you discard. + +## TTLs + +RFC 8484 requires DNS TTLs to be reduced by a response's HTTP `Age`, and RFC +2181 says a TTL with its high bit set means zero. `DohResolver` applies both to +JSON answers. Wire bodies keep the TTLs the resolver sent: pass them through +`adjustDnsWireTtls(body, age: response.age)` before caching, or apply +`effectiveDnsTtl` to TTLs you decode. Verify any SIG(0) or TSIG transaction +signature first, since adjusting TTLs changes the signed message. ## Development diff --git a/lib/doh_resolver.dart b/lib/doh_resolver.dart index fa6e2c6..8399320 100644 --- a/lib/doh_resolver.dart +++ b/lib/doh_resolver.dart @@ -1,3 +1,6 @@ +/// Bounded DNS over HTTPS: JSON TXT resolution with explicit authentication +/// evidence, and RFC 8484 wire-format helpers. Platform-independent; use +/// `package:doh_resolver/doh_resolver_io.dart` for the `dart:io` transport. library; export 'src/dns.dart'; diff --git a/lib/doh_resolver_io.dart b/lib/doh_resolver_io.dart index 0355a69..4096794 100644 --- a/lib/doh_resolver_io.dart +++ b/lib/doh_resolver_io.dart @@ -1,3 +1,5 @@ +/// Everything in `doh_resolver`, plus [IoDohTransport] for direct and SOCKS5 +/// HTTPS on `dart:io` platforms. library; export 'package:socks_socket/socks_connection.dart' show SocksCredentials; diff --git a/lib/src/dns.dart b/lib/src/dns.dart index a8a5244..562f9f9 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -10,6 +10,8 @@ sealed class DnsException implements Exception { String toString() => '$runtimeType: $message'; } +/// A name that is not an ASCII DNS name of at most 253 octets with +/// labels of letters, digits, `_` or `-`. final class DnsInvalidNameException extends DnsException { const DnsInvalidNameException(super.message); } @@ -19,10 +21,13 @@ final class DnsInvalidQueryException extends DnsException { const DnsInvalidQueryException(super.message); } +/// A response, or caller-supplied evidence, that does not parse or is +/// internally inconsistent. final class DnsMalformedResponseException extends DnsException { const DnsMalformedResponseException(super.message); } +/// A response that exceeded a configured size or count bound. final class DnsResponseTooLargeException extends DnsException { const DnsResponseTooLargeException(super.message); } @@ -33,10 +38,13 @@ final class DnsQueryFailedException extends DnsException { const DnsQueryFailedException(super.message, {required this.rcode}); } +/// A response that was not marked authenticated, had checking disabled, or +/// was truncated, so it carries no resolver DNSSEC assertion. final class DnsAuthenticationUnavailableException extends DnsException { const DnsAuthenticationUnavailableException(super.message); } +/// A response, or CNAME chain, that does not answer the question asked. final class DnsQuestionMismatchException extends DnsException { const DnsQuestionMismatchException(super.message); } @@ -46,14 +54,17 @@ sealed class DnsAliasException extends DnsException { const DnsAliasException(super.message); } +/// A CNAME chain that revisits an owner. final class DnsAliasLoopException extends DnsAliasException { const DnsAliasLoopException(super.message); } +/// An owner with several CNAMEs, or with both a CNAME and TXT records. final class DnsAliasAmbiguousException extends DnsAliasException { const DnsAliasAmbiguousException(super.message); } +/// A CNAME chain longer than the configured owner limit. final class DnsAliasTooLongException extends DnsAliasException { const DnsAliasTooLongException(super.message); } @@ -65,10 +76,13 @@ final class DnsTransportException extends DnsException { const DnsTransportException(super.message, {this.httpStatus}); } +/// An operation cancelled by its caller or by closing its transport. final class DnsCancelledException extends DnsException { const DnsCancelledException(super.message); } +/// An operation that reached its own deadline. Connection establishment +/// timeouts are reported as [DnsTransportException]. final class DnsTimeoutException extends DnsException { const DnsTimeoutException(super.message); } @@ -84,6 +98,8 @@ int effectiveDnsTtl(int ttl, {Duration age = Duration.zero}) { return max(0, (ttl > 0x7fffffff ? 0 : ttl) - age.inSeconds); } +/// Lower-cases [name], strips one trailing dot, and checks it is a valid +/// ASCII DNS name; throws [DnsInvalidNameException] otherwise. String normalizeDnsName(String name) { if (name.codeUnits.any((c) => c > 127)) { throw const DnsInvalidNameException("Use ASCII DNS names"); @@ -99,12 +115,15 @@ String normalizeDnsName(String name) { return value; } +/// A question from a JSON DNS response. class DnsQuestion { final String name; final int type; const DnsQuestion(this.name, this.type); } +/// A resource record from a JSON DNS response, in the resolver's +/// presentation format. Parsed records hold an [effectiveDnsTtl]. class DnsRecord { final String name; final int type; @@ -117,6 +136,7 @@ class DnsRecord { required this.data}); } +/// The octets of one TXT RRset member, with its character-strings joined. class DnsTxtRecord { final String name; final int ttl; @@ -129,6 +149,8 @@ class DnsTxtRecord { } } + /// [bytes] decoded as UTF-8; throws [DnsMalformedResponseException] if + /// they are not valid UTF-8. String get text { try { return utf8.decode(bytes); @@ -138,8 +160,15 @@ class DnsTxtRecord { } } +/// Where an [AuthenticatedTxtResult]'s authenticity claim comes from. +/// +/// [resolverAsserted] means the HTTPS resolver set the AD flag; trust rests on +/// that resolver. [locallyValidated] means some [DnsValidationIssuer] in this +/// process vouched for it, so also check [AuthenticatedTxtResult.validator]. enum DnsAuthentication { resolverAsserted, locallyValidated } +/// The identity of a [DnsValidationIssuer]. Compare it with [identical]; +/// [name] is only a label and is not unique. final class DnsValidator { final String name; DnsValidator._(this.name); @@ -148,6 +177,12 @@ final class DnsValidator { String toString() => 'DnsValidator($name)'; } +/// Mints [DnsAuthentication.locallyValidated] results for a local DNSSEC +/// validator. +/// +/// Anyone can construct an issuer, so `locallyValidated` alone proves nothing. +/// Keep your issuer private and accept a result only when +/// `identical(result.validator, myIssuer.validator)`. final class DnsValidationIssuer { final DnsValidator validator; DnsValidationIssuer(String name) : validator = DnsValidator._(name) { @@ -155,6 +190,9 @@ final class DnsValidationIssuer { } // Call only after signatures are verified; this checks owner binding. + /// Issues evidence for [records] at [canonicalName], reached from + /// [question] through [aliases]. Call only after verifying the signatures; + /// this checks owner binding and bounds, not cryptography. AuthenticatedTxtResult issueTxt({ required String question, required String canonicalName, @@ -216,6 +254,11 @@ final class DnsValidationIssuer { } } +/// TXT records whose authenticity is attested by [authentication]. +/// +/// Only this package can create one: from an AD-flagged resolver response, or +/// through a [DnsValidationIssuer]. An empty [records] list is an +/// authenticated NODATA answer. final class AuthenticatedTxtResult { final String question; final String canonicalName; @@ -233,6 +276,7 @@ final class AuthenticatedTxtResult { aliases = List.unmodifiable(aliases); } +/// A bounded, validated JSON DNS response from [resolver]. class DnsResponse { final Uri resolver; final int status; @@ -241,7 +285,8 @@ class DnsResponse { final bool? truncated; final List questions; final List answers; - // Answer TTLs are already reduced by this HTTP cache age. + + /// The HTTP cache age already subtracted from the answer TTLs. final Duration age; DnsResponse._( this.resolver, @@ -253,6 +298,8 @@ class DnsResponse { this.answers, this.age); + /// Validates [json] from a DoH JSON API. Answer TTLs are reduced by the + /// response's HTTP cache [age] and normalized by [effectiveDnsTtl]. factory DnsResponse.fromJson(Object? json, {required Uri resolver, int maxAnswers = 64, @@ -327,6 +374,9 @@ class DnsResponse { age); } + /// The TXT records answering [domain], following at most [maxOwnerVisits] + /// owners through CNAMEs. Requires a successful, AD-flagged, untruncated + /// response with checking enabled and a single matching TXT question. AuthenticatedTxtResult authenticatedTxt(String domain, {required DnsTxtFormat txtFormat, int maxOwnerVisits = 8}) { domain = normalizeDnsName(domain); diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart index 63e38e4..8f336aa 100644 --- a/lib/src/io_transport.dart +++ b/lib/src/io_transport.dart @@ -8,6 +8,8 @@ import 'dns.dart'; import 'resolver.dart'; import 'wire.dart'; +/// A SOCKS5 proxy, addressed numerically so no local DNS lookup is needed. +/// Use [SocksCredentials.isolation] for Tor stream isolation. class SocksProxy { final InternetAddress host; final int port; @@ -15,18 +17,39 @@ class SocksProxy { const SocksProxy(this.host, this.port, {this.credentials}); } +/// A `dart:io` HTTPS/1.1 DoH transport, direct or through SOCKS5. +/// +/// It verifies the endpoint certificate, never follows redirects, never falls +/// back from a configured proxy to a direct connection, bounds bodies and +/// deadlines, and admits at most [maxConcurrentRequests] running plus +/// [maxQueuedRequests] waiting operations. Call [close] when done. class IoDohTransport implements DohTransport, DohWireTransport { final SocksProxy? proxy; + + /// Sends every proxied request to this address instead of letting the proxy + /// resolve the endpoint host. It applies to all endpoints used with this + /// transport; TLS still verifies the requested host name. final InternetAddress? proxyDestination; final SecurityContext? securityContext; final Duration connectionTimeout; + + /// The deadline for each operation, including time spent queued. final Duration requestTimeout; final Duration idleTimeout; final int maxConcurrentRequests; final int maxQueuedRequests; + + /// Whether wire queries are padded with [padDnsWireQuery]. final bool padQueries; - // Defaults to off with a proxy: a shared connection lets the resolver link - // lookups that would otherwise travel over separate (e.g. Tor) streams. + + /// Whether wire queries to the same origin share connections. Defaults to + /// off with a [proxy]: a shared connection lets the resolver link lookups + /// that would otherwise travel over separate (for example Tor) streams. + /// The transport rewrites each wire ID to keep it unique on a connection + /// and restores the caller's ID in the response, except for SIG(0)- or + /// TSIG-signed queries and `preserveMessage` exchanges, whose IDs are kept + /// and still never repeat on a connection. JSON requests never share + /// connections. final bool reuseConnections; int _active = 0; bool _closed = false; @@ -84,6 +107,9 @@ class IoDohTransport implements DohTransport, DohWireTransport { return _start(uri, min(maxResponseBytes, 65535), checked, preserved); } + /// Fails queued and running operations with [DnsCancelledException], fails + /// later starts the same way, and begins closing every connection. Socket + /// teardown finishes asynchronously after this returns. Future close() async { _closed = true; for (final request in [..._waiting, ..._running]) { diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index dcb9e04..6d1cf5a 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -5,18 +5,29 @@ import 'dns.dart'; /// A started request. Starting never throws: every failure, including /// invalid input, completes [result] with a [DnsException]. class DnsOperation { + /// Completes with the outcome. Like any [Future], an error nobody listens + /// to is reported as uncaught; cancelling does not consume it, so attach a + /// handler or call [FutureExtensions.ignore] on results you discard. final Future result; + + /// Fails [result] with [DnsCancelledException] unless it has already + /// completed, and releases the work. Safe to call repeatedly. final void Function() cancel; const DnsOperation(this.result, this.cancel); + + /// An operation that has already failed with [error]. DnsOperation.failed(DnsException error) : this(Future.error(error), _noop); } void _noop() {} +/// A complete HTTPS response from a DoH resolver. class DohHttpResponse { final int statusCode; final List body; - // Time the response spent in HTTP caches; DNS TTLs must be reduced by it. + + /// Time the response spent in HTTP caches, from its `Age` header. DNS TTLs + /// must be reduced by it; see [effectiveDnsTtl] and [adjustDnsWireTtls]. final Duration age; DohHttpResponse(this.statusCode, List body, {this.age = Duration.zero}) : body = List.unmodifiable(body) { @@ -24,10 +35,17 @@ class DohHttpResponse { } } +/// Sends DoH JSON GET requests. +/// +/// Implementations must deliver every failure through the returned +/// [DnsOperation.result], bound the body to `maxResponseBytes`, and make +/// `cancel` idempotent. [DohResolver] cancels only operations that have not +/// completed. abstract interface class DohTransport { DnsOperation start(Uri uri, {required int maxResponseBytes}); } +/// Sends RFC 8484 wire-format queries; see [DohTransport] for the contract. abstract interface class DohWireTransport { /// Sends wire [query]. With [preserveMessage], the query is sent and the /// response returned exactly as exchanged, with no padding or ID rewrite, @@ -36,14 +54,32 @@ abstract interface class DohWireTransport { {required int maxResponseBytes, bool preserveMessage = false}); } +/// Looks up TXT records through a DoH JSON API and accepts only answers the +/// resolver asserts are DNSSEC-authenticated. +/// +/// This trusts the resolver's AD flag: use a local validator with +/// [DnsValidationIssuer] when the resolver must not choose the answer. class DohResolver { final DohTransport transport; final Uri endpoint; + + /// The deadline for a whole lookup. The transport may enforce its own, + /// too; whichever is earlier applies. final Duration timeout; final int maxResponseBytes; + + /// How [endpoint] writes TXT data. Known Google and Cloudflare hosts get + /// their format by default; any other endpoint must pass it explicitly. final DnsTxtFormat txtFormat; // Decoded once so a malformed endpoint fails construction, not each lookup. late final Map> _endpointParameters; + + /// Creates a resolver for [endpoint], Google's JSON API by default. + /// + /// Throws [ArgumentError] for a non-HTTPS, credential-bearing, + /// fragment-bearing or undecodable endpoint, invalid limits, or an unknown + /// provider without [txtFormat]. Endpoint query parameters are kept, + /// including repeated ones, except those the resolver sets itself. DohResolver( {required this.transport, Uri? endpoint, @@ -73,9 +109,17 @@ class DohResolver { } } + /// Looks up [domain]'s TXT records; see [startTxtLookup]. Future lookupTxt(String domain) => startTxtLookup(domain).result; + /// Starts a cancellable TXT lookup for [domain]. + /// + /// The request asks for DNSSEC data with checking enabled, sends + /// `edns_client_subnet=0.0.0.0/0` (honoured by providers that implement it, + /// such as Google), and pads the URL to a multiple of 128 characters so the + /// name length is hidden. Never throws: every failure is a [DnsException] in + /// the result. DnsOperation startTxtLookup(String domain) { try { domain = normalizeDnsName(domain); diff --git a/lib/src/wire.dart b/lib/src/wire.dart index 2be1fe9..0cca200 100644 --- a/lib/src/wire.dart +++ b/lib/src/wire.dart @@ -7,6 +7,9 @@ const _mismatch = DnsQuestionMismatchException('DNS wire response does not match query'); /// This is framing only: it never makes the answer trustworthy. +/// Checks that wire [response] is a complete, well-formed answer to [query]: +/// same ID, opcode and questions, not truncated, and EDNS version 0. +/// Throws [DnsInvalidQueryException] for an unusable [query]. void validateDnsWireResponse(List query, List response) { final sent = _parseOrNull(query); if (sent == null || sent.isResponse) { @@ -51,6 +54,10 @@ List adjustDnsWireTtls(List response, {required Duration age}) { return List.unmodifiable(adjusted); } +/// Pads [query] to a multiple of [blockSize] octets with an RFC 7830 +/// padding option, as RFC 8467 recommends. Queries without a final OPT +/// record, already padded, or signed (signatures must stay last) are +/// returned unchanged. List padDnsWireQuery(List query, {int blockSize = 128}) { if (blockSize < 1 || blockSize > 512) { throw ArgumentError.value(blockSize, 'blockSize'); From 928f814e30f23d9b680e130ae7871a06f2b155fe Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 13:53:42 -0500 Subject: [PATCH 12/22] fix(transport): reject unusable endpoint ports before taking a request slot Uri.parse accepts any digit string as a port and only throws when the port is read. IoDohTransport read it in _origin after creating the request and incrementing _active, so an endpoint such as https://dns.google:99999999999999999999/resolve threw a synchronous FormatException from start, left a request whose deadline later failed with a null-check error, and leaked a concurrency slot: with maxConcurrentRequests 1 every later lookup timed out. isUsableDohEndpoint now checks the scheme, host, user info, fragment and a readable port in 1-65535 before any state exists, and _start fails the operation with DnsInvalidQueryException. DohResolver applies the same check at construction so such an endpoint cannot make startTxtLookup throw either. --- lib/src/io_transport.dart | 8 +++----- lib/src/resolver.dart | 8 +++----- lib/src/wire.dart | 17 +++++++++++++++++ test/dns_test.dart | 6 +++++- test/io_wire_transport_test.dart | 16 ++++++++++++++-- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart index 8f336aa..f79b769 100644 --- a/lib/src/io_transport.dart +++ b/lib/src/io_transport.dart @@ -122,11 +122,9 @@ class IoDohTransport implements DohTransport, DohWireTransport { DnsOperation _start(Uri uri, int maxResponseBytes, [List? query, bool preserved = false]) { - if (uri.scheme != 'https' || - uri.host.isEmpty || - uri.userInfo.isNotEmpty || - uri.hasFragment || - maxResponseBytes < 1) { + // Checked before any request state exists: an unreadable port would + // otherwise throw from _origin after _active was already taken. + if (!isUsableDohEndpoint(uri) || maxResponseBytes < 1) { return DnsOperation.failed( const DnsInvalidQueryException('Expected a bounded HTTPS request')); } diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index 6d1cf5a..1914eb2 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dns.dart'; +import 'wire.dart'; /// A started request. Starting never throws: every failure, including /// invalid input, completes [result] with a [DnsException]. @@ -77,7 +78,7 @@ class DohResolver { /// Creates a resolver for [endpoint], Google's JSON API by default. /// /// Throws [ArgumentError] for a non-HTTPS, credential-bearing, - /// fragment-bearing or undecodable endpoint, invalid limits, or an unknown + /// fragment-bearing, unusably-ported or undecodable endpoint, invalid limits, or an unknown /// provider without [txtFormat]. Endpoint query parameters are kept, /// including repeated ones, except those the resolver sets itself. DohResolver( @@ -91,10 +92,7 @@ class DohResolver { _knownTxtFormat(endpoint.host) ?? (throw ArgumentError.value(endpoint, 'endpoint', 'Pass txtFormat: JSON TXT encoding differs between providers')) { - if (this.endpoint.scheme != 'https' || - this.endpoint.host.isEmpty || - this.endpoint.userInfo.isNotEmpty || - this.endpoint.hasFragment) { + if (!isUsableDohEndpoint(this.endpoint)) { throw ArgumentError( 'A credential-free HTTPS resolver endpoint is required'); } diff --git a/lib/src/wire.dart b/lib/src/wire.dart index 0cca200..47235d5 100644 --- a/lib/src/wire.dart +++ b/lib/src/wire.dart @@ -87,6 +87,23 @@ List padDnsWireQuery(List query, {int blockSize = 128}) { return List.unmodifiable(padded); } +/// Whether [uri] is a usable HTTPS endpoint: no credentials or fragment, a +/// host, and a port that parses and fits 1-65535. `Uri` accepts any digits as +/// a port and only throws when the port is read. +bool isUsableDohEndpoint(Uri uri) { + if (uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasFragment) { + return false; + } + try { + return uri.port >= 1 && uri.port <= 65535; + } on FormatException { + return false; + } +} + List checkedDnsWireQuery(List query) { final message = _parseOrNull(query); if (message == null || message.isResponse) { diff --git a/test/dns_test.dart b/test/dns_test.dart index 7d40dac..3c6ee18 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -395,10 +395,14 @@ void main() { operation.cancel(); expect(transport.uri, isNull); }); - test('endpoints with undecodable query parameters are rejected up front', () { + test('endpoints with undecodable parameters or ports are rejected up front', + () { for (final endpoint in [ 'https://resolver.example/resolve?x=%FF', 'https://resolver.example/resolve?%C3=1', + 'https://resolver.example:0/resolve', + 'https://resolver.example:70000/resolve', + 'https://resolver.example:99999999999999999999/resolve', ]) { expect( () => DohResolver( diff --git a/test/io_wire_transport_test.dart b/test/io_wire_transport_test.dart index 08b6052..4ba5e75 100644 --- a/test/io_wire_transport_test.dart +++ b/test/io_wire_transport_test.dart @@ -85,8 +85,11 @@ void main() { test('rejects invalid queries and URIs through the result, unconnected', () async { var requests = 0; - server.listen((_) => requests++); - final transport = IoDohTransport(securityContext: pki.clientContext()); + serve(inspect: (_, __) => requests++); + // One slot: a rejected start that leaked it would hang the valid request. + final transport = IoDohTransport( + securityContext: pki.clientContext(), maxConcurrentRequests: 1); + addTearDown(transport.close); for (final query in [ [], wireReply(), @@ -100,6 +103,10 @@ void main() { endpoint().replace(scheme: 'http'), endpoint().replace(userInfo: 'user'), endpoint().replace(fragment: 'x'), + endpoint().replace(port: 0), + endpoint().replace(port: 70000), + // Uri.parse accepts this; reading the port throws FormatException. + Uri.parse('https://localhost:99999999999999999999/dns-query'), ]) { await expectLater( transport.startWire(uri, wireQuery(), maxResponseBytes: 512).result, @@ -113,6 +120,11 @@ void main() { .result, throwsA(isA())); expect(requests, 0); + final response = await transport + .startWire(endpoint(), wireQuery(), maxResponseBytes: 512) + .result; + expect(response.statusCode, 200); + expect(requests, 1); }); for (final (name, code, status, type, reply) in [ From 2e88978630574237848e8ec79929e33c6bc36389 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 13:54:29 -0500 Subject: [PATCH 13/22] perf(wire): cache decoded names so pointer chains take linear work _name re-walked every backward pointer chain for each name it decoded and only remembered which offsets were valid targets. A valid message near the 65,535-octet limit can build progressively longer chains, so decompression was quadratic: one 60 KB reply cost about 11.9 million byte reads on the isolate that also runs deadlines and UI work. Each decoded name now caches the suffix starting at every label it owns, as views into one buffer, and a pointer appends that suffix in one step. Targets must still be the start of a label in a name decoded before the current one began, so loops stay impossible and the accepted set of messages is unchanged. compression_budget_test counts byte reads rather than time so the regression is deterministic. --- lib/src/wire.dart | 45 ++++++++++++++++++------------ test/compression_budget_test.dart | 46 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 test/compression_budget_test.dart diff --git a/lib/src/wire.dart b/lib/src/wire.dart index 47235d5..3e88ff9 100644 --- a/lib/src/wire.dart +++ b/lib/src/wire.dart @@ -145,7 +145,10 @@ class _Question { class _WireMessage { final List data; final questions = <_Question>[]; - final _nameStarts = {}; + // Pointer targets are the starts of fully decoded names and their label + // suffixes. Caching the decoded suffix makes each pointer O(1) instead of + // re-walking a chain the sender controls. + final _names = >{}; int? optRdlengthOffset; int? optVersion; bool optIsLast = false; @@ -370,35 +373,30 @@ class _WireMessage { _skip(data[_offset - 1], limit); } - // Pointers must move strictly backwards past every earlier jump, so loops are impossible. + // A pointer may only target a name decoded before this one began, so chains + // cannot loop and every jump costs one cached lookup. List _name({int? limit, bool compressed = true}) { final name = []; + final starts = <(int, int)>[]; var at = _offset; - var bound = limit ?? data.length; - var lowest = _offset; - int? resume; + final bound = limit ?? data.length; while (true) { if (at >= bound) throw _malformed; - _nameStarts.add(at); + starts.add((at, name.length)); final length = data[at]; if (length == 0) { name.add(0); - if (name.length > 255) throw _malformed; - _offset = resume ?? at + 1; - return name; + at++; + break; } switch (length & 0xc0) { case 0xc0: if (!compressed || at + 1 >= bound) throw _malformed; final target = (length & 0x3f) << 8 | data[at + 1]; - if (target < 12 || - target >= lowest || - !_nameStarts.contains(target)) { - throw _malformed; - } - resume ??= at + 2; - lowest = at = target; - bound = data.length; + final suffix = _names[target]; + if (target >= _offset || suffix == null) throw _malformed; + name.addAll(suffix); + at += 2; case 0: if (at + 1 + length > bound) throw _malformed; name.add(length); @@ -408,9 +406,22 @@ class _WireMessage { } if (name.length + 1 > 255) throw _malformed; at += 1 + length; + continue; default: throw _malformed; } + break; + } + if (name.length > 255) throw _malformed; + final decoded = Uint8List.fromList(name); + for (final (offset, index) in starts) { + // Pointer offsets are 14 bits; views share one buffer rather than + // allocating a list for every suffix of a long name. + if (offset < 0x4000) { + _names[offset] = Uint8List.sublistView(decoded, index); + } } + _offset = at; + return decoded; } } diff --git a/test/compression_budget_test.dart b/test/compression_budget_test.dart new file mode 100644 index 0000000..a70ba89 --- /dev/null +++ b/test/compression_budget_test.dart @@ -0,0 +1,46 @@ +import 'dart:collection'; + +import 'package:doh_resolver/doh_resolver.dart'; +import 'package:test/test.dart'; + +import 'support/dns_wire.dart'; + +// Count byte reads instead of timing: expose quadratic decompression reliably. +class CountingBytes extends ListBase { + final List bytes; + int reads = 0; + CountingBytes(this.bytes); + @override + int get length => bytes.length; + @override + set length(int value) => throw UnsupportedError('immutable'); + @override + int operator [](int index) { + reads++; + return bytes[index]; + } + + @override + void operator []=(int index, int value) => + throw UnsupportedError('immutable'); +} + +void main() { + test('backward pointer chains take bounded linear work', () { + // DNS compression offsets are 14 bits. Build a chain entirely below 16 KiB, + // then refer to its tail for the remainder of a near-maximum-size message. + final records = >[]; + var previous = 12; + var offset = 12 + wireQuestion(wireName('alice.example')).length; + for (var i = 0; i < 5000; i++) { + final record = + wireRecord([0xc0 | (previous >> 8), previous & 255], 99, []); + records.add(record); + if (offset < 16384) previous = offset; + offset += record.length; + } + final response = CountingBytes(wireReply(answers: records)); + validateDnsWireResponse(wireQuery(), response); + expect(response.reads, lessThan(response.length * 30)); + }); +} From 15ecf365bf4606d991e089476758a98981ad77f7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 13:55:17 -0500 Subject: [PATCH 14/22] feat(dns): let local validators extend DnsAuthenticationUnavailableException Sealing DnsException gave callers exhaustive switches but closed every subtype, and dnssec_resolver reports its bogus and indeterminate results through a DnsException subclass. It can no longer compile against this package. DnsAuthenticationUnavailableException is now a base class, the one open extension point: a local validator subclasses it to say why it found no secure answer, and a switch over DnsException still sees that as authentication being unavailable. Every other subtype stays final. --- CHANGELOG.md | 3 ++- README.md | 5 ++++- lib/src/dns.dart | 6 +++++- test/dns_test.dart | 7 +++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07ce7a..e2be89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,4 +20,5 @@ and exchanges started with `preserveMessage`, keep their exact bytes. Reuse is off by default when a proxy is set. - Every failure is a subtype of the sealed `DnsException`, delivered through - `DnsOperation.result`; starting an operation never throws. + `DnsOperation.result`; starting an operation never throws. Local validators + may extend `DnsAuthenticationUnavailableException` for their own failures. diff --git a/README.md b/README.md index 2567507..f66879b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,10 @@ verifies the requested host name. Every failure is a subtype of the sealed `DnsException`, so you can catch one case, all CNAME failures through `DnsAliasException`, or switch over every case exhaustively. `DnsQueryFailedException` carries the DNS `rcode`, and -`DnsTransportException` carries `httpStatus` for a non-200 response. +`DnsTransportException` carries `httpStatus` for a non-200 response. The one +open subtype is `DnsAuthenticationUnavailableException`: a local validator +extends it to say why it found no secure answer, and an exhaustive switch +still sees that as authentication being unavailable. Starting an operation never throws: invalid names, URIs and wire queries, a closed transport and a full queue all complete `DnsOperation.result` with a diff --git a/lib/src/dns.dart b/lib/src/dns.dart index 562f9f9..4a261db 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -40,7 +40,11 @@ final class DnsQueryFailedException extends DnsException { /// A response that was not marked authenticated, had checking disabled, or /// was truncated, so it carries no resolver DNSSEC assertion. -final class DnsAuthenticationUnavailableException extends DnsException { +/// +/// Local validators may extend this to report why their own validation did +/// not establish a secure answer; `switch` over [DnsException] still treats +/// every such subtype as this case. +base class DnsAuthenticationUnavailableException extends DnsException { const DnsAuthenticationUnavailableException(super.message); } diff --git a/test/dns_test.dart b/test/dns_test.dart index 3c6ee18..9c1f8b8 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -100,6 +100,7 @@ void main() { DnsTimeoutException() => 'timeout', }; expect(describe(const DnsAliasLoopException('loop')), 'alias'); + expect(describe(const _LocalValidationException()), 'unauthenticated'); expect(describe(const DnsQueryFailedException('x', rcode: 3)), 'rcode 3'); expect(const DnsTimeoutException('late').toString(), 'DnsTimeoutException: late'); @@ -516,3 +517,9 @@ class _ThrowingTransport implements DohTransport { {required int maxResponseBytes}) => throw error; } + +/// What a local DNSSEC validator reports: the one open extension point. +final class _LocalValidationException + extends DnsAuthenticationUnavailableException { + const _LocalValidationException() : super('bogus'); +} From a165c25840a5c2877532ea46bc935f46378531a1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 13:55:17 -0500 Subject: [PATCH 15/22] docs(readme): say when a proxied transport should opt back into reuse Reuse is off behind a proxy so a long-lived transport cannot let the resolver link unrelated lookups. A transport created per lookup with SocksCredentials.isolation gains nothing from that default: its queries already share one circuit, and a wire lookup makes several requests, so each costs a new Tor stream and TLS handshake. Measured on such a transport, reuse cut a Tor lookup from 7.2 s to 4.9 s. Say so next to the default. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f66879b..e273847 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,11 @@ verifies the requested host name. - **Connection reuse.** JSON requests never share connections. Wire queries to the same origin do, unless a `proxy` is set: lookups sharing a connection can be linked by the resolver, so proxied transports reuse only with - `reuseConnections: true`. The transport gives each exchange a random ID that + `reuseConnections: true`. Pass it when the transport already lives for one + lookup on its own isolated stream, as with `SocksCredentials.isolation`: + those queries share a circuit anyway, and a wire lookup makes several + requests, so each new Tor stream and TLS handshake is pure cost. The + transport gives each exchange a random ID that is unique on its connection and restores the caller's ID in the response, so callers may send ID 0 as RFC 8484 recommends. SIG(0)- and TSIG-signed queries are sent byte-for-byte, because their signatures cover the header. Pass From dd65127d3f3c71b3a83df64333b67af0fd2f4d4b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 13:55:17 -0500 Subject: [PATCH 16/22] docs(dns): drop a comment that repeats issueTxt's dartdoc --- lib/src/dns.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/dns.dart b/lib/src/dns.dart index 4a261db..290981b 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -193,7 +193,6 @@ final class DnsValidationIssuer { if (name.isEmpty) throw ArgumentError.value(name, 'name'); } - // Call only after signatures are verified; this checks owner binding. /// Issues evidence for [records] at [canonicalName], reached from /// [question] through [aliases]. Call only after verifying the signatures; /// this checks owner binding and bounds, not cryptography. From 2ac2509ede986a783a7a57648380f73f8c0b3eeb Mon Sep 17 00:00:00 2001 From: slopbot9k Date: Tue, 13 Oct 2026 13:52:21 -0600 Subject: [PATCH 17/22] Revert "feat(dns): let local validators extend DnsAuthenticationUnavailableException" This reverts commit 15ecf365bf4606d991e089476758a98981ad77f7. --- CHANGELOG.md | 3 +-- README.md | 5 +---- lib/src/dns.dart | 6 +----- test/dns_test.dart | 7 ------- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2be89c..c07ce7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,5 +20,4 @@ and exchanges started with `preserveMessage`, keep their exact bytes. Reuse is off by default when a proxy is set. - Every failure is a subtype of the sealed `DnsException`, delivered through - `DnsOperation.result`; starting an operation never throws. Local validators - may extend `DnsAuthenticationUnavailableException` for their own failures. + `DnsOperation.result`; starting an operation never throws. diff --git a/README.md b/README.md index e273847..17daff6 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,7 @@ verifies the requested host name. Every failure is a subtype of the sealed `DnsException`, so you can catch one case, all CNAME failures through `DnsAliasException`, or switch over every case exhaustively. `DnsQueryFailedException` carries the DNS `rcode`, and -`DnsTransportException` carries `httpStatus` for a non-200 response. The one -open subtype is `DnsAuthenticationUnavailableException`: a local validator -extends it to say why it found no secure answer, and an exhaustive switch -still sees that as authentication being unavailable. +`DnsTransportException` carries `httpStatus` for a non-200 response. Starting an operation never throws: invalid names, URIs and wire queries, a closed transport and a full queue all complete `DnsOperation.result` with a diff --git a/lib/src/dns.dart b/lib/src/dns.dart index 290981b..c69c044 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -40,11 +40,7 @@ final class DnsQueryFailedException extends DnsException { /// A response that was not marked authenticated, had checking disabled, or /// was truncated, so it carries no resolver DNSSEC assertion. -/// -/// Local validators may extend this to report why their own validation did -/// not establish a secure answer; `switch` over [DnsException] still treats -/// every such subtype as this case. -base class DnsAuthenticationUnavailableException extends DnsException { +final class DnsAuthenticationUnavailableException extends DnsException { const DnsAuthenticationUnavailableException(super.message); } diff --git a/test/dns_test.dart b/test/dns_test.dart index 9c1f8b8..3c6ee18 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -100,7 +100,6 @@ void main() { DnsTimeoutException() => 'timeout', }; expect(describe(const DnsAliasLoopException('loop')), 'alias'); - expect(describe(const _LocalValidationException()), 'unauthenticated'); expect(describe(const DnsQueryFailedException('x', rcode: 3)), 'rcode 3'); expect(const DnsTimeoutException('late').toString(), 'DnsTimeoutException: late'); @@ -517,9 +516,3 @@ class _ThrowingTransport implements DohTransport { {required int maxResponseBytes}) => throw error; } - -/// What a local DNSSEC validator reports: the one open extension point. -final class _LocalValidationException - extends DnsAuthenticationUnavailableException { - const _LocalValidationException() : super('bogus'); -} From f9901fddd00013c84f562c995a0d97aacf45a112 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 17:00:29 -0500 Subject: [PATCH 18/22] refactor!: drop local validation evidence types --- CHANGELOG.md | 2 - README.md | 14 +--- example/doh_resolver_example.dart | 2 +- lib/src/dns.dart | 118 +++--------------------------- lib/src/resolver.dart | 9 +-- test/dns_test.dart | 44 +---------- test/live_test.dart | 1 - test/local_evidence_test.dart | 106 --------------------------- 8 files changed, 20 insertions(+), 276 deletions(-) delete mode 100644 test/local_evidence_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c07ce7a..cd8bef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ untruncated, checking-enabled answers to the exact question, following CNAME chains strictly. JSON TXT data is decoded per provider through `DnsTxtFormat`, defaulting for Google and Cloudflare hosts. -- Local-validation evidence issued by `DnsValidationIssuer.issueTxt`, carrying - the issuing `DnsValidator` for identity checks. - Wire-format helpers: `validateDnsWireResponse` for strict framing and query/response binding, `padDnsWireQuery` for RFC 8467 padding, and `adjustDnsWireTtls` for cache-ready TTLs. diff --git a/README.md b/README.md index 17daff6..c933b29 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,7 @@ try { `DohResolver` accepts an answer only when the resolver asserts it is DNSSEC-authenticated: the AD flag is set, checking is enabled, the reply is not truncated, and its single TXT question matches the requested name. The result's -`DnsAuthentication.resolverAsserted` evidence means you trust that resolver; it -is not local DNSSEC validation. Use a local validator, such as -`dnssec_resolver`, when the resolver itself must not choose the answer. - -A local validator reports its results through a `DnsValidationIssuer`. Anyone -can construct an issuer, so `DnsAuthentication.locallyValidated` and the -validator's name prove nothing on their own. Keep your issuer private and check -its identity: - -```dart -bool trusted(AuthenticatedTxtResult result) => - identical(result.validator, myIssuer.validator); -``` +presence means you trust that resolver; it is not local DNSSEC validation. `validateDnsWireResponse` checks message framing and that a response answers its query; it does not validate DNSSEC signatures or make answer data trusted. diff --git a/example/doh_resolver_example.dart b/example/doh_resolver_example.dart index fe3d921..1cc270c 100644 --- a/example/doh_resolver_example.dart +++ b/example/doh_resolver_example.dart @@ -10,7 +10,7 @@ Future main(List arguments) async { final resolver = DohResolver(transport: transport); try { final result = await resolver.lookupTxt(arguments.single); - print('Asserted by ${result.resolver} (${result.authentication.name})'); + print('Asserted by ${result.resolver}'); for (final record in result.records) { print('${record.name} TTL=${record.ttl}: ${record.text}'); } diff --git a/lib/src/dns.dart b/lib/src/dns.dart index c69c044..d2dff0a 100644 --- a/lib/src/dns.dart +++ b/lib/src/dns.dart @@ -160,121 +160,27 @@ class DnsTxtRecord { } } -/// Where an [AuthenticatedTxtResult]'s authenticity claim comes from. +/// TXT records from an AD-flagged resolver response. An empty [records] list is +/// a NODATA answer the resolver asserts is authenticated. /// -/// [resolverAsserted] means the HTTPS resolver set the AD flag; trust rests on -/// that resolver. [locallyValidated] means some [DnsValidationIssuer] in this -/// process vouched for it, so also check [AuthenticatedTxtResult.validator]. -enum DnsAuthentication { resolverAsserted, locallyValidated } - -/// The identity of a [DnsValidationIssuer]. Compare it with [identical]; -/// [name] is only a label and is not unique. -final class DnsValidator { - final String name; - DnsValidator._(this.name); - - @override - String toString() => 'DnsValidator($name)'; -} - -/// Mints [DnsAuthentication.locallyValidated] results for a local DNSSEC -/// validator. -/// -/// Anyone can construct an issuer, so `locallyValidated` alone proves nothing. -/// Keep your issuer private and accept a result only when -/// `identical(result.validator, myIssuer.validator)`. -final class DnsValidationIssuer { - final DnsValidator validator; - DnsValidationIssuer(String name) : validator = DnsValidator._(name) { - if (name.isEmpty) throw ArgumentError.value(name, 'name'); - } - - /// Issues evidence for [records] at [canonicalName], reached from - /// [question] through [aliases]. Call only after verifying the signatures; - /// this checks owner binding and bounds, not cryptography. - AuthenticatedTxtResult issueTxt({ - required String question, - required String canonicalName, - required Uri resolver, - required List records, - List aliases = const [], - }) { - question = normalizeDnsName(question); - canonicalName = normalizeDnsName(canonicalName); - if (records.isEmpty || - records.length > 64 || - aliases.length > 7 || - records.any((r) => - normalizeDnsName(r.name) != canonicalName || - r.ttl < 0 || - r.ttl > 0xffffffff)) { - throw const DnsMalformedResponseException('Invalid validated TXT result'); - } - var owner = question; - final seen = {owner}; - final chain = []; - for (final alias in aliases) { - if (alias.type != 5 || - normalizeDnsName(alias.name) != owner || - alias.ttl < 0 || - alias.ttl > 0xffffffff) { - throw const DnsMalformedResponseException( - 'Invalid validated CNAME chain'); - } - chain.add(DnsRecord( - name: owner, - type: 5, - ttl: effectiveDnsTtl(alias.ttl), - data: alias.data)); - owner = normalizeDnsName(alias.data); - if (!seen.add(owner)) { - throw const DnsAliasLoopException('CNAME loop'); - } - } - if (owner != canonicalName) { - throw const DnsQuestionMismatchException( - 'CNAME chain does not reach TXT owner'); - } - return AuthenticatedTxtResult._( - question, - canonicalName, - resolver, - [ - for (final r in records) - DnsTxtRecord( - name: canonicalName, - ttl: effectiveDnsTtl(r.ttl), - bytes: r.bytes) - ], - chain, - null, - DnsAuthentication.locallyValidated, - validator); - } -} - -/// TXT records whose authenticity is attested by [authentication]. -/// -/// Only this package can create one: from an AD-flagged resolver response, or -/// through a [DnsValidationIssuer]. An empty [records] list is an -/// authenticated NODATA answer. -final class AuthenticatedTxtResult { +/// Records only the resolver's AD assertion; it is not validation evidence. +final class ResolverAssertedTxtResult { final String question; final String canonicalName; final Uri resolver; final List records; final List aliases; final DnsResponse? response; - final DnsAuthentication authentication; - final DnsValidator? validator; - AuthenticatedTxtResult._(this.question, this.canonicalName, this.resolver, - List records, List aliases, this.response, - [this.authentication = DnsAuthentication.resolverAsserted, - this.validator]) + ResolverAssertedTxtResult._(this.question, this.canonicalName, this.resolver, + List records, List aliases, this.response) : records = List.unmodifiable(records), aliases = List.unmodifiable(aliases); } +/// Use [ResolverAssertedTxtResult]: the result asserts nothing itself. +@Deprecated('Use ResolverAssertedTxtResult') +typedef AuthenticatedTxtResult = ResolverAssertedTxtResult; + /// A bounded, validated JSON DNS response from [resolver]. class DnsResponse { final Uri resolver; @@ -376,7 +282,7 @@ class DnsResponse { /// The TXT records answering [domain], following at most [maxOwnerVisits] /// owners through CNAMEs. Requires a successful, AD-flagged, untruncated /// response with checking enabled and a single matching TXT question. - AuthenticatedTxtResult authenticatedTxt(String domain, + ResolverAssertedTxtResult authenticatedTxt(String domain, {required DnsTxtFormat txtFormat, int maxOwnerVisits = 8}) { domain = normalizeDnsName(domain); if (status != 0) { @@ -404,7 +310,7 @@ class DnsResponse { final cnames = records.where((record) => record.type == 5).toList(); final txt = records.where((record) => record.type == 16).toList(); if (cnames.isEmpty) { - return AuthenticatedTxtResult._( + return ResolverAssertedTxtResult._( domain, owner, resolver, diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index 1914eb2..1154bfa 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -58,8 +58,7 @@ abstract interface class DohWireTransport { /// Looks up TXT records through a DoH JSON API and accepts only answers the /// resolver asserts are DNSSEC-authenticated. /// -/// This trusts the resolver's AD flag: use a local validator with -/// [DnsValidationIssuer] when the resolver must not choose the answer. +/// This trusts the resolver's AD flag. class DohResolver { final DohTransport transport; final Uri endpoint; @@ -108,7 +107,7 @@ class DohResolver { } /// Looks up [domain]'s TXT records; see [startTxtLookup]. - Future lookupTxt(String domain) => + Future lookupTxt(String domain) => startTxtLookup(domain).result; /// Starts a cancellable TXT lookup for [domain]. @@ -118,7 +117,7 @@ class DohResolver { /// such as Google), and pads the URL to a multiple of 128 characters so the /// name length is hidden. Never throws: every failure is a [DnsException] in /// the result. - DnsOperation startTxtLookup(String domain) { + DnsOperation startTxtLookup(String domain) { try { domain = normalizeDnsName(domain); } on DnsException catch (error) { @@ -148,7 +147,7 @@ class DohResolver { return DnsOperation.failed( const DnsTransportException('DNS transport failed')); } - final result = Completer(); + final result = Completer(); late Timer timer; // Only an unfinished transport operation is cancelled; once its result // has completed, the transport owns no further work for this lookup. diff --git a/test/dns_test.dart b/test/dns_test.dart index 3c6ee18..e829c15 100644 --- a/test/dns_test.dart +++ b/test/dns_test.dart @@ -54,7 +54,8 @@ void main() { expect(result.aliases, hasLength(1)); expect(result.response, same(response)); expect(result.records.first.ttl, 60); - expect(result.authentication, DnsAuthentication.resolverAsserted); + // ignore: deprecated_member_use_from_same_package + expect(result, isA()); }); for (final field in ['AD', 'CD', 'TC']) { test('missing or unacceptable $field never authenticates', () { @@ -199,34 +200,6 @@ void main() { expect(() => DnsTxtRecord(name: 'x', ttl: 1, bytes: [255]).text, throwsA(isA())); }); - test('validated evidence enforces DNS TTL bounds', () { - final issuer = DnsValidationIssuer('test'); - for (final ttl in [-1, 0x100000000]) { - expect( - () => issuer.issueTxt( - question: 'alice.example', - canonicalName: 'alice.example', - resolver: Uri.https('resolver.example', '/dns-query'), - records: [ - DnsTxtRecord(name: 'alice.example', ttl: ttl, bytes: [1]) - ]), - throwsA(isA())); - } - expect( - issuer - .issueTxt( - question: 'alice.example', - canonicalName: 'alice.example', - resolver: Uri.https('resolver.example', '/dns-query'), - records: [ - DnsTxtRecord( - name: 'alice.example', ttl: 0xffffffff, bytes: [1]) - ]) - .records - .single - .ttl, - 0); - }); test('TTLs with the high bit set are treated as zero', () { for (final (raw, ttl) in [(0x7fffffff, 0x7fffffff), (0x80000000, 0)]) { final result = decode(reply(answers: [ @@ -235,19 +208,6 @@ void main() { ])).authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw); expect(result.records.single.ttl, ttl); expect(result.aliases.single.ttl, ttl); - final issued = DnsValidationIssuer('test').issueTxt( - question: 'alice.example', - canonicalName: 'pay.example', - resolver: Uri.https('resolver.example', '/dns-query'), - records: [ - DnsTxtRecord(name: 'pay.example', ttl: raw, bytes: [1]) - ], - aliases: [ - DnsRecord( - name: 'alice.example', type: 5, ttl: raw, data: 'pay.example') - ]); - expect(issued.records.single.ttl, ttl); - expect(issued.aliases.single.ttl, ttl); } }); test('raw TXT is taken verbatim and quoted TXT must be quoted', () { diff --git a/test/live_test.dart b/test/live_test.dart index a544763..2087446 100644 --- a/test/live_test.dart +++ b/test/live_test.dart @@ -43,7 +43,6 @@ void main() { final result = await DohResolver(transport: IoDohTransport(), endpoint: endpoint) .lookupTxt('getmonero.org'); - expect(result.authentication, DnsAuthentication.resolverAsserted); expect(result.records, isNotEmpty); }, skip: skip); } diff --git a/test/local_evidence_test.dart b/test/local_evidence_test.dart deleted file mode 100644 index d715c64..0000000 --- a/test/local_evidence_test.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:doh_resolver/doh_resolver.dart'; -import 'package:test/test.dart'; - -void main() { - final endpoint = Uri.https('resolver.example', '/dns-query'); - final issuer = DnsValidationIssuer('test validator'); - final txt = DnsTxtRecord(name: 'bob.example', ttl: 60, bytes: [65]); - final cname = - DnsRecord(name: 'alice.example', type: 5, ttl: 60, data: 'bob.example'); - - test('local evidence preserves provenance without inventing remote flags', - () { - final result = issuer.issueTxt( - question: 'alice.example', - canonicalName: 'bob.example', - resolver: endpoint, - records: [txt], - aliases: [cname]); - expect(result.authentication, DnsAuthentication.locallyValidated); - expect(result.validator, same(issuer.validator)); - expect(result.response, isNull); - expect(() => result.records.clear(), throwsUnsupportedError); - }); - - test('local evidence rejects broken owner binding', () { - for (final aliases in >[ - [], - [DnsRecord(name: 'other.example', type: 5, ttl: 60, data: 'bob.example')] - ]) { - expect( - () => issuer.issueTxt( - question: 'alice.example', - canonicalName: 'bob.example', - resolver: endpoint, - records: [txt], - aliases: aliases), - throwsA(isA())); - } - expect( - () => issuer.issueTxt( - question: 'alice.example', - canonicalName: 'alice.example', - resolver: endpoint, - records: [txt]), - throwsA(isA())); - }); - - test('validator identity cannot be claimed by another issuer', () { - final other = DnsValidationIssuer('test validator'); - final forged = other.issueTxt( - question: 'bob.example', - canonicalName: 'bob.example', - resolver: endpoint, - records: [txt]); - expect(forged.authentication, DnsAuthentication.locallyValidated); - expect(identical(forged.validator, issuer.validator), isFalse); - expect(other.validator.name, issuer.validator.name); - }); - - test('issued evidence snapshots caller records', () { - final bytes = [65]; - final result = issuer.issueTxt( - question: 'Alice.Example.', - canonicalName: 'bob.example', - resolver: endpoint, - records: [DnsTxtRecord(name: 'BOB.example', ttl: 60, bytes: bytes)], - aliases: [cname]); - bytes[0] = 66; - expect(result.question, 'alice.example'); - expect(result.records.single.name, 'bob.example'); - expect(result.records.single.text, 'A'); - expect(result.aliases.single.name, 'alice.example'); - }); - - test('TXT records hold only octets', () { - for (final bytes in [ - [256], - [-1], - [65, 0x1f600] - ]) { - expect(() => DnsTxtRecord(name: 'bob.example', ttl: 60, bytes: bytes), - throwsArgumentError); - } - expect(DnsTxtRecord(name: 'bob.example', ttl: 60, bytes: [0, 255]).bytes, - [0, 255]); - }); - - test('resolver-asserted results name no local validator', () { - final response = DnsResponse.fromJson({ - 'Status': 0, - 'AD': true, - 'CD': false, - 'TC': false, - 'Question': [ - {'name': 'bob.example.', 'type': 16} - ], - 'Answer': [ - {'name': 'bob.example.', 'type': 16, 'TTL': 60, 'data': 'A'} - ], - }, resolver: endpoint); - final result = - response.authenticatedTxt('bob.example', txtFormat: DnsTxtFormat.raw); - expect(result.authentication, DnsAuthentication.resolverAsserted); - expect(result.validator, isNull); - }); -} From 8af3ce3f370ecf88c162cc70b1edbbcff89a8030 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 13 Oct 2026 18:06:19 -0500 Subject: [PATCH 19/22] refactor!: split JSON resolver and consume standalone DoH transport Move the DNS wire builders covered by test/support/LICENSE into doh_transport under the package MIT LICENSE. --- .github/workflows/test.yml | 12 + CHANGELOG.md | 21 - README.md | 116 ---- lib/doh_resolver.dart | 9 - lib/doh_resolver_io.dart | 8 - lib/src/io_transport.dart | 427 --------------- lib/src/wire.dart | 427 --------------- .../doh_resolver/.pubignore | 5 +- packages/doh_resolver/CHANGELOG.md | 10 + LICENSE => packages/doh_resolver/LICENSE | 0 packages/doh_resolver/README.md | 68 +++ .../doh_resolver/analysis_options.yaml | 0 .../example}/doh_resolver_example.dart | 0 packages/doh_resolver/lib/doh_resolver.dart | 26 + .../doh_resolver/lib/doh_resolver_io.dart | 3 + .../doh_resolver/lib}/src/dns.dart | 121 +---- .../doh_resolver/lib}/src/resolver.dart | 54 +- packages/doh_resolver/pubspec.yaml | 12 + .../doh_resolver/test}/dns_test.dart | 37 +- .../doh_resolver/test}/example_test.dart | 0 packages/doh_resolver/test/live_test.dart | 49 ++ .../doh_resolver/tool}/check.dart | 0 pubspec.yaml | 14 +- test/compression_budget_test.dart | 46 -- test/io_connection_reuse_test.dart | 506 ------------------ test/io_transport_test.dart | 277 ---------- test/io_wire_transport_test.dart | 311 ----------- test/live_test.dart | 60 --- test/support/LICENSE | 22 - test/support/dns_wire.dart | 110 ---- test/support/scripted_server.dart | 88 --- test/support/test_pki.dart | 215 -------- test/support/tunnel_proxy.dart | 145 ----- test/wire_test.dart | 424 --------------- 34 files changed, 198 insertions(+), 3425 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 README.md delete mode 100644 lib/doh_resolver.dart delete mode 100644 lib/doh_resolver_io.dart delete mode 100644 lib/src/io_transport.dart delete mode 100644 lib/src/wire.dart rename .pubignore => packages/doh_resolver/.pubignore (73%) create mode 100644 packages/doh_resolver/CHANGELOG.md rename LICENSE => packages/doh_resolver/LICENSE (100%) create mode 100644 packages/doh_resolver/README.md rename analysis_options.yaml => packages/doh_resolver/analysis_options.yaml (100%) rename {example => packages/doh_resolver/example}/doh_resolver_example.dart (100%) create mode 100644 packages/doh_resolver/lib/doh_resolver.dart create mode 100644 packages/doh_resolver/lib/doh_resolver_io.dart rename {lib => packages/doh_resolver/lib}/src/dns.dart (71%) rename {lib => packages/doh_resolver/lib}/src/resolver.dart (74%) create mode 100644 packages/doh_resolver/pubspec.yaml rename {test => packages/doh_resolver/test}/dns_test.dart (92%) rename {test => packages/doh_resolver/test}/example_test.dart (100%) create mode 100644 packages/doh_resolver/test/live_test.dart rename {tool => packages/doh_resolver/tool}/check.dart (100%) delete mode 100644 test/compression_budget_test.dart delete mode 100644 test/io_connection_reuse_test.dart delete mode 100644 test/io_transport_test.dart delete mode 100644 test/io_wire_transport_test.dart delete mode 100644 test/live_test.dart delete mode 100755 test/support/LICENSE delete mode 100644 test/support/dns_wire.dart delete mode 100644 test/support/scripted_server.dart delete mode 100644 test/support/test_pki.dart delete mode 100644 test/support/tunnel_proxy.dart delete mode 100644 test/wire_test.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aba94d9..80992a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,4 +16,16 @@ jobs: - uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d with: sdk: ${{ matrix.sdk }} + # doh_transport is not on pub.dev yet; resolve it from a sibling clone + # the same way the README describes for development. + - run: git clone --depth 1 --branch main https://github.com/cypherstack/doh_transport.git ../doh_transport + shell: bash + - run: | + cat > pubspec_overrides.yaml <<'YAML' + dependency_overrides: + doh_transport: + path: ../doh_transport + YAML + shell: bash - run: dart tool/check.dart + working-directory: packages/doh_resolver diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index cd8bef6..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,21 +0,0 @@ -## 0.0.1 - -- Initial release. -- `DohResolver`: bounded JSON TXT lookups that accept only resolver-asserted, - untruncated, checking-enabled answers to the exact question, following CNAME - chains strictly. JSON TXT data is decoded per provider through `DnsTxtFormat`, - defaulting for Google and Cloudflare hosts. -- Wire-format helpers: `validateDnsWireResponse` for strict framing and - query/response binding, `padDnsWireQuery` for RFC 8467 padding, and - `adjustDnsWireTtls` for cache-ready TTLs. -- TTLs honour RFC 2181 (high-bit TTLs are zero) and RFC 8484 HTTP `Age`, also - exposed as `effectiveDnsTtl`. -- `IoDohTransport`: direct or SOCKS5 HTTPS through `socks_socket` ^2.0.0, with - certificate verification, no redirects or proxy fallback, bounded bodies, - deadlines, concurrency and queueing, and `close()`. -- Wire connection reuse per origin with transport-owned transaction IDs, so ID 0 - queries reuse connections while stale replies stay detectable. Signed queries, - and exchanges started with `preserveMessage`, keep their exact bytes. Reuse is - off by default when a proxy is set. -- Every failure is a subtype of the sealed `DnsException`, delivered through - `DnsOperation.result`; starting an operation never throws. diff --git a/README.md b/README.md deleted file mode 100644 index c933b29..0000000 --- a/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# doh_resolver - -Bounded DNS over HTTPS for Dart: JSON TXT resolution with explicit -authentication evidence, RFC 8484 wire-format helpers, and a `dart:io` -transport for direct or SOCKS5 HTTPS. - -```dart -import 'package:doh_resolver/doh_resolver_io.dart'; - -final transport = IoDohTransport(); -try { - final result = await DohResolver(transport: transport).lookupTxt('example.com'); - for (final record in result.records) { - print('${record.ttl}s ${record.text}'); - } -} on DnsQueryFailedException catch (error) { - print('DNS rcode ${error.rcode}'); -} on DnsException catch (error) { - print('Rejected: $error'); -} finally { - await transport.close(); -} -``` - -## Trust model - -`DohResolver` accepts an answer only when the resolver asserts it is -DNSSEC-authenticated: the AD flag is set, checking is enabled, the reply is not -truncated, and its single TXT question matches the requested name. The result's -presence means you trust that resolver; it is not local DNSSEC validation. - -`validateDnsWireResponse` checks message framing and that a response answers -its query; it does not validate DNSSEC signatures or make answer data trusted. - -## JSON providers - -DoH JSON APIs are not standardized, and providers encode TXT data differently: -Google returns the text verbatim, Cloudflare returns quoted character-strings -with escapes. A value alone cannot reveal which form it is in, so -`DohResolver.txtFormat` is chosen per endpoint. Google and Cloudflare hosts get -the right `DnsTxtFormat` by default; any other endpoint must pass `txtFormat`. - -Lookups send `edns_client_subnet=0.0.0.0/0`, which asks providers that -implement it, such as Google, not to forward client subnet data. Cloudflare -does not send client subnets by default. The query URL is padded to a multiple -of 128 characters to hide the name's length. Endpoint query parameters are -kept, including repeated ones, except those the resolver sets itself. - -## Transport - -`IoDohTransport` requires HTTPS, verifies the endpoint certificate, never -follows redirects, bounds response bodies and deadlines, and never falls back -from a configured SOCKS proxy to a direct connection. The proxy resolves -endpoint host names unless `proxyDestination` supplies an address; that -override applies to every endpoint used with the transport, while TLS still -verifies the requested host name. - -- **Limits.** `maxConcurrentRequests` operations run at once and - `maxQueuedRequests` wait; a full queue fails new operations with - `DnsTransportException`. -- **Deadlines.** `requestTimeout` covers each operation, including time spent - queued. `DohResolver.timeout` covers a whole lookup. Whichever is earlier - applies. Connection timeouts are reported as `DnsTransportException`; only an - operation's own deadline is `DnsTimeoutException`. -- **Connection reuse.** JSON requests never share connections. Wire queries to - the same origin do, unless a `proxy` is set: lookups sharing a connection can - be linked by the resolver, so proxied transports reuse only with - `reuseConnections: true`. Pass it when the transport already lives for one - lookup on its own isolated stream, as with `SocksCredentials.isolation`: - those queries share a circuit anyway, and a wire lookup makes several - requests, so each new Tor stream and TLS handshake is pure cost. The - transport gives each exchange a random ID that - is unique on its connection and restores the caller's ID in the response, so - callers may send ID 0 as RFC 8484 recommends. SIG(0)- and TSIG-signed queries - are sent byte-for-byte, because their signatures cover the header. Pass - `preserveMessage: true` to `startWire` when you expect a signed response to - an unsigned query: a SIG(0) response covers the request as sent, so the query - is then sent unpadded with its own ID and the response returned untouched. - Any malformed, cancelled, timed-out or failed exchange retires its - connection. -- **Shutdown.** `close()` fails queued and running operations with - `DnsCancelledException` and begins closing connections; socket teardown - finishes asynchronously. -- **HTTP/1.1 only.** Dart's `HttpClient` does not speak HTTP/2, so concurrent - operations use separate connections. - -## Errors and cancellation - -Every failure is a subtype of the sealed `DnsException`, so you can catch one -case, all CNAME failures through `DnsAliasException`, or switch over every case -exhaustively. `DnsQueryFailedException` carries the DNS `rcode`, and -`DnsTransportException` carries `httpStatus` for a non-200 response. - -Starting an operation never throws: invalid names, URIs and wire queries, a -closed transport and a full queue all complete `DnsOperation.result` with a -`DnsException`. `ArgumentError` is reserved for invalid constructor arguments. - -`DnsOperation.cancel` fails an unfinished operation with -`DnsCancelledException`. It does not consume that error: like any `Future`, a -result nobody listens to reports its error as uncaught, so attach a handler or -call `.ignore()` on results you discard. - -## TTLs - -RFC 8484 requires DNS TTLs to be reduced by a response's HTTP `Age`, and RFC -2181 says a TTL with its high bit set means zero. `DohResolver` applies both to -JSON answers. Wire bodies keep the TTLs the resolver sent: pass them through -`adjustDnsWireTtls(body, age: response.age)` before caching, or apply -`effectiveDnsTtl` to TTLs you decode. Verify any SIG(0) or TSIG transaction -signature first, since adjusting TTLs changes the signed message. - -## Development - -Run every pre-merge check (format, analyze, tests, publish dry-run) on any -platform with `dart tool/check.dart`. Live resolver tests are skipped unless -`DOH_LIVE_TESTS=1` is set. diff --git a/lib/doh_resolver.dart b/lib/doh_resolver.dart deleted file mode 100644 index 8399320..0000000 --- a/lib/doh_resolver.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// Bounded DNS over HTTPS: JSON TXT resolution with explicit authentication -/// evidence, and RFC 8484 wire-format helpers. Platform-independent; use -/// `package:doh_resolver/doh_resolver_io.dart` for the `dart:io` transport. -library; - -export 'src/dns.dart'; -export 'src/resolver.dart'; -export 'src/wire.dart' - show adjustDnsWireTtls, padDnsWireQuery, validateDnsWireResponse; diff --git a/lib/doh_resolver_io.dart b/lib/doh_resolver_io.dart deleted file mode 100644 index 4096794..0000000 --- a/lib/doh_resolver_io.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Everything in `doh_resolver`, plus [IoDohTransport] for direct and SOCKS5 -/// HTTPS on `dart:io` platforms. -library; - -export 'package:socks_socket/socks_connection.dart' show SocksCredentials; - -export 'doh_resolver.dart'; -export 'src/io_transport.dart'; diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart deleted file mode 100644 index f79b769..0000000 --- a/lib/src/io_transport.dart +++ /dev/null @@ -1,427 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; -import 'package:socks_socket/socks_connection.dart'; -import 'dns.dart'; -import 'resolver.dart'; -import 'wire.dart'; - -/// A SOCKS5 proxy, addressed numerically so no local DNS lookup is needed. -/// Use [SocksCredentials.isolation] for Tor stream isolation. -class SocksProxy { - final InternetAddress host; - final int port; - final SocksCredentials? credentials; - const SocksProxy(this.host, this.port, {this.credentials}); -} - -/// A `dart:io` HTTPS/1.1 DoH transport, direct or through SOCKS5. -/// -/// It verifies the endpoint certificate, never follows redirects, never falls -/// back from a configured proxy to a direct connection, bounds bodies and -/// deadlines, and admits at most [maxConcurrentRequests] running plus -/// [maxQueuedRequests] waiting operations. Call [close] when done. -class IoDohTransport implements DohTransport, DohWireTransport { - final SocksProxy? proxy; - - /// Sends every proxied request to this address instead of letting the proxy - /// resolve the endpoint host. It applies to all endpoints used with this - /// transport; TLS still verifies the requested host name. - final InternetAddress? proxyDestination; - final SecurityContext? securityContext; - final Duration connectionTimeout; - - /// The deadline for each operation, including time spent queued. - final Duration requestTimeout; - final Duration idleTimeout; - final int maxConcurrentRequests; - final int maxQueuedRequests; - - /// Whether wire queries are padded with [padDnsWireQuery]. - final bool padQueries; - - /// Whether wire queries to the same origin share connections. Defaults to - /// off with a [proxy]: a shared connection lets the resolver link lookups - /// that would otherwise travel over separate (for example Tor) streams. - /// The transport rewrites each wire ID to keep it unique on a connection - /// and restores the caller's ID in the response, except for SIG(0)- or - /// TSIG-signed queries and `preserveMessage` exchanges, whose IDs are kept - /// and still never repeat on a connection. JSON requests never share - /// connections. - final bool reuseConnections; - int _active = 0; - bool _closed = false; - final _waiting = Queue<_IoRequest>(); - final _running = <_IoRequest>{}; - final _idle = <_Lane>[]; - // Lanes fork from here: forking from Zone.current during a queued handoff - // would parent each new lane on its predecessor and keep the chain alive. - final _zone = Zone.current; - final _random = Random.secure(); - IoDohTransport( - {this.proxy, - this.proxyDestination, - this.securityContext, - this.connectionTimeout = const Duration(seconds: 5), - this.requestTimeout = const Duration(seconds: 30), - this.idleTimeout = const Duration(seconds: 5), - this.maxConcurrentRequests = 8, - this.maxQueuedRequests = 64, - this.padQueries = true, - bool? reuseConnections}) - : reuseConnections = reuseConnections ?? proxy == null { - if (proxy == null && proxyDestination != null) { - throw ArgumentError('proxyDestination requires an explicit proxy'); - } - if (connectionTimeout <= Duration.zero || - requestTimeout <= Duration.zero || - idleTimeout <= Duration.zero || - maxConcurrentRequests < 1 || - maxQueuedRequests < 0) { - throw ArgumentError('Invalid transport limits'); - } - } - - @override - DnsOperation start(Uri uri, - {required int maxResponseBytes}) { - return _start(uri, maxResponseBytes); - } - - @override - DnsOperation startWire(Uri uri, List query, - {required int maxResponseBytes, bool preserveMessage = false}) { - final List checked; - final bool preserved; - try { - // SIG(0) and TSIG sign the header, so signed queries are always kept. - preserved = preserveMessage || isSignedDnsWireQuery(query); - checked = padQueries && !preserved - ? padDnsWireQuery(query) - : checkedDnsWireQuery(query); - } on DnsException catch (error) { - return DnsOperation.failed(error); - } - return _start(uri, min(maxResponseBytes, 65535), checked, preserved); - } - - /// Fails queued and running operations with [DnsCancelledException], fails - /// later starts the same way, and begins closing every connection. Socket - /// teardown finishes asynchronously after this returns. - Future close() async { - _closed = true; - for (final request in [..._waiting, ..._running]) { - request.cancel(); - } - for (final lane in [..._idle]) { - _retire(lane); - } - } - - DnsOperation _start(Uri uri, int maxResponseBytes, - [List? query, bool preserved = false]) { - // Checked before any request state exists: an unreadable port would - // otherwise throw from _origin after _active was already taken. - if (!isUsableDohEndpoint(uri) || maxResponseBytes < 1) { - return DnsOperation.failed( - const DnsInvalidQueryException('Expected a bounded HTTPS request')); - } - if (_closed) { - return DnsOperation.failed( - const DnsCancelledException('DNS transport closed')); - } - if (_active >= maxConcurrentRequests && - _waiting.length >= maxQueuedRequests) { - return DnsOperation.failed( - const DnsTransportException('DNS transport request queue is full')); - } - final request = _IoRequest(this, uri, maxResponseBytes, query, preserved); - if (_active < maxConcurrentRequests) { - _active++; - _run(request); - } else { - _waiting.add(request); - } - return DnsOperation(request.result.future, request.cancel); - } - - void _run(_IoRequest request) { - _Lane? lane; - final origin = _origin(request.uri); - if (request.query case final query?) { - final callerId = query[0] << 8 | query[1]; - for (var i = _idle.length - 1; i >= 0; i--) { - final candidate = _idle[i]; - if (candidate.origin == origin && - (request.preserved - ? candidate.canClaim(callerId) - : candidate.hasFreeWireIds)) { - _idle.removeAt(i); - lane = candidate; - break; - } - } - } - if (lane == null) { - // Keep the total number of live clients bounded by the concurrency cap. - // A client is pinned to one HTTPS origin so its per-host pool cannot grow - // across caller-selected resolver endpoints. - if (_idle.isNotEmpty) _retire(_idle.last); - lane = _newLane(origin); - } - lane.owner = request; - request.lane = lane; - if (request.query case final query?) { - // The transport owns the ID on the wire. Callers may all send ID 0 as - // RFC 8484 recommends, yet a stale reply on a reused connection still - // fails the ID check instead of answering the next request. Preserved - // queries keep their ID, which is still never repeated on a connection, - // and their response is returned exactly as received. - final callerId = query[0] << 8 | query[1]; - if (request.preserved) { - lane.claimWireId(_random, callerId); - request.sent = query; - } else { - final id = lane.claimWireId(_random); - request.sent = Uint8List.fromList(query) - ..[0] = id >> 8 - ..[1] = id & 0xff; - } - } - _running.add(request); - lane.zone.run(request.run); - } - - _Lane _newLane(_Origin origin) { - final lane = _Lane(origin); - lane.zone = _zone.fork(specification: - ZoneSpecification(handleUncaughtError: (_, __, ___, error, stack) { - if (lane.owner case final owner?) { - owner._finish( - const DnsTransportException('Resolver connection failed'), stack); - } else { - _retire(lane); - } - })); - lane.client = lane.zone.run(() { - final client = HttpClient(context: securityContext) - ..connectionTimeout = connectionTimeout - ..idleTimeout = idleTimeout - ..maxConnectionsPerHost = 1 - ..autoUncompress = false - ..userAgent = null - ..findProxy = (_) => 'DIRECT'; - final proxy = this.proxy; - if (proxy != null) { - client.connectionFactory = (destination, _, __) async { - final owner = lane.owner; - if (owner == null || - owner._done || - _origin(destination) != lane.origin || - _origin(owner.uri) != lane.origin) { - throw const DnsCancelledException( - 'DNS connection cancelled or redirected'); - } - final task = SocksConnection.start( - proxyHost: proxy.host, - proxyPort: proxy.port, - targetHost: proxyDestination?.address ?? owner.uri.host, - targetPort: owner.uri.port, - tlsHost: owner.uri.host, - securityContext: securityContext, - credentials: proxy.credentials, - timeout: connectionTimeout); - lane.tasks.add(task); - task.socket.then((_) => lane.tasks.remove(task), - onError: (Object _) => lane.tasks.remove(task)); - return task; - }; - } - return client; - }); - return lane; - } - - void _retire(_Lane lane) { - if (lane.retired) return; - lane.retired = true; - lane.owner = null; - _idle.remove(lane); - lane.client.close(force: true); - for (final task in [...lane.tasks]) { - task.cancel(); - } - lane.tasks.clear(); - } - - void _release(_IoRequest request, bool clean) { - if (_waiting.remove(request)) return; - _running.remove(request); - final lane = request.lane!; - if (clean && - request.query != null && - reuseConnections && - !_closed && - !lane.retired) { - lane.owner = null; - _idle.add(lane); - } else { - _retire(lane); - } - _active--; - while (_waiting.isNotEmpty && _active < maxConcurrentRequests) { - _active++; - _run(_waiting.removeFirst()); - } - } -} - -// RFC 9111 §5.1: use the first member of a list-valued or repeated Age, and -// ignore it if that member is not delta-seconds. Values beyond 2^31 saturate. -Duration _age(List? values) { - if (values == null || values.isEmpty) return Duration.zero; - final first = values.first.split(',').first.trim(); - if (!RegExp(r'^[0-9]+$').hasMatch(first)) return Duration.zero; - final digits = first.replaceFirst(RegExp(r'^0+'), ''); - final seconds = digits.length > 10 ? null : int.parse('0$digits'); - return Duration(seconds: min(seconds ?? 0x80000000, 0x80000000)); -} - -typedef _Origin = ({String scheme, String host, int port}); - -_Origin _origin(Uri uri) => ( - scheme: uri.scheme, - host: uri.host, - port: uri.hasPort ? uri.port : 443, - ); - -class _Lane { - final _Origin origin; - late final Zone zone; - late final HttpClient client; - final tasks = >{}; - final _usedWireIds = Uint8List(8192); - var _usedWireIdCount = 0; - _IoRequest? owner; - bool retired = false; - - _Lane(this.origin); - - // Retiring at half the ID space keeps random selection to ~2 draws. - bool get hasFreeWireIds => _usedWireIdCount < 32768; - - bool canClaim(int id) => hasFreeWireIds && !_isUsed(id); - - bool _isUsed(int id) => _usedWireIds[id >> 3] & (1 << (id & 7)) != 0; - - /// Claims [id], or a random unused ID when none is given. - int claimWireId(Random random, [int? id]) { - id ??= () { - while (true) { - final candidate = random.nextInt(65536); - if (!_isUsed(candidate)) return candidate; - } - }(); - assert(!_isUsed(id)); - _usedWireIds[id >> 3] |= 1 << (id & 7); - _usedWireIdCount++; - return id; - } -} - -class _IoRequest { - final IoDohTransport transport; - final Uri uri; - final int limit; - final List? query; - List? sent; - final bool preserved; - final result = Completer(); - _Lane? lane; - late final Timer _deadline; - bool _done = false; - _IoRequest(this.transport, this.uri, this.limit, this.query, this.preserved) { - _deadline = Timer(transport.requestTimeout, - () => _finish(const DnsTimeoutException('DNS request timed out'))); - } - - void _finish(Object outcome, [StackTrace? stack]) { - if (_done) return; - _done = true; - _deadline.cancel(); - transport._release(this, outcome is DohHttpResponse); - if (outcome is DohHttpResponse) { - result.complete(outcome); - } else { - result.completeError(outcome, stack); - } - } - - void cancel() => - _finish(const DnsCancelledException('DNS request cancelled')); - - Future run() async { - if (_done) return; - try { - final request = - await lane!.client.openUrl(query == null ? 'GET' : 'POST', uri); - if (_done) { - request.abort(); - return; - } - request.followRedirects = false; - request.headers.removeAll(HttpHeaders.acceptEncodingHeader); - request.headers.set(HttpHeaders.acceptHeader, - query == null ? 'application/dns-json' : 'application/dns-message'); - if (sent case final sent?) { - request.headers.contentType = ContentType('application', 'dns-message'); - request.contentLength = sent.length; - request.add(sent); - } - final response = await request.close(); - if (response.statusCode != 200) { - throw DnsTransportException('Resolver HTTP request failed', - httpStatus: response.statusCode); - } - final mime = response.headers.contentType?.mimeType; - if (query == null - ? mime != 'application/json' && mime != 'application/dns-json' - : mime != 'application/dns-message') { - throw const DnsMalformedResponseException( - 'Unexpected DNS response media type'); - } - if (response.headers.value(HttpHeaders.contentEncodingHeader) - case final encoding? when encoding.toLowerCase() != 'identity') { - throw const DnsMalformedResponseException('Encoded DNS response'); - } - if (response.contentLength > limit) { - throw const DnsResponseTooLargeException('DNS body exceeds limit'); - } - final bytes = BytesBuilder(copy: false); - await for (final chunk in response) { - if (bytes.length + chunk.length > limit) { - throw const DnsResponseTooLargeException('DNS body exceeds limit'); - } - bytes.add(chunk); - if (_done) return; - } - final body = bytes.takeBytes(); - if (sent case final sent?) { - validateDnsWireResponse(sent, body); - body - ..[0] = query![0] - ..[1] = query![1]; - } - _finish(DohHttpResponse(response.statusCode, body, - age: _age(response.headers[HttpHeaders.ageHeader]))); - } catch (error, stack) { - _finish( - error is DnsException - ? error - : const DnsTransportException('Resolver connection failed'), - stack); - } - } -} diff --git a/lib/src/wire.dart b/lib/src/wire.dart deleted file mode 100644 index 3e88ff9..0000000 --- a/lib/src/wire.dart +++ /dev/null @@ -1,427 +0,0 @@ -import 'dart:typed_data'; - -import 'dns.dart'; - -const _malformed = DnsMalformedResponseException('Malformed DNS wire message'); -const _mismatch = - DnsQuestionMismatchException('DNS wire response does not match query'); - -/// This is framing only: it never makes the answer trustworthy. -/// Checks that wire [response] is a complete, well-formed answer to [query]: -/// same ID, opcode and questions, not truncated, and EDNS version 0. -/// Throws [DnsInvalidQueryException] for an unusable [query]. -void validateDnsWireResponse(List query, List response) { - final sent = _parseOrNull(query); - if (sent == null || sent.isResponse) { - throw const DnsInvalidQueryException('Invalid DNS wire query'); - } - final reply = _WireMessage.parse(response); - if (!reply.isResponse) throw _malformed; - if (reply.flags & 0x0200 != 0) { - throw const DnsMalformedResponseException('Truncated DNS wire response'); - } - if (reply.id != sent.id || - reply.opcode != sent.opcode || - reply.questions.length != sent.questions.length) { - throw _mismatch; - } - for (var i = 0; i < sent.questions.length; i++) { - if (!reply.questions[i].matches(sent.questions[i])) throw _mismatch; - } - if (reply.optVersion case final version? when version != 0) { - throw _malformed; - } -} - -/// A copy of wire [response] with every record TTL replaced by its -/// [effectiveDnsTtl] after [age], such as `DohHttpResponse.age`. OPT is left -/// alone: its TTL field carries EDNS flags. RRSIG validation is unaffected -/// because it uses the signature's Original TTL, but SIG(0) and TSIG -/// transaction signatures cover the whole message, so verify them first. -List adjustDnsWireTtls(List response, {required Duration age}) { - if (age < Duration.zero) throw ArgumentError.value(age, 'age'); - final message = _WireMessage.parse(response); - final adjusted = Uint8List.fromList(response); - for (final at in message.ttlOffsets) { - final ttl = message._u16(at) << 16 | message._u16(at + 2); - final value = effectiveDnsTtl(ttl, age: age); - adjusted - ..[at] = value >> 24 - ..[at + 1] = value >> 16 & 0xff - ..[at + 2] = value >> 8 & 0xff - ..[at + 3] = value & 0xff; - } - return List.unmodifiable(adjusted); -} - -/// Pads [query] to a multiple of [blockSize] octets with an RFC 7830 -/// padding option, as RFC 8467 recommends. Queries without a final OPT -/// record, already padded, or signed (signatures must stay last) are -/// returned unchanged. -List padDnsWireQuery(List query, {int blockSize = 128}) { - if (blockSize < 1 || blockSize > 512) { - throw ArgumentError.value(blockSize, 'blockSize'); - } - final message = _parseOrNull(query); - if (message == null || message.isResponse) { - throw const DnsInvalidQueryException('Invalid DNS wire query'); - } - final rdlength = message.optRdlengthOffset; - if (rdlength == null || !message.optIsLast || message.optHasPadding) { - return List.unmodifiable(query); - } - final padding = (blockSize - (query.length + 4) % blockSize) % blockSize; - final length = query.length + 4 + padding; - final optLength = (query[rdlength] << 8 | query[rdlength + 1]) + 4 + padding; - if (length > 65535 || optLength > 65535) { - return List.unmodifiable(query); - } - final padded = Uint8List(length) - ..setRange(0, query.length, query) - ..[rdlength] = optLength >> 8 - ..[rdlength + 1] = optLength & 0xff - ..[query.length] = 0 - ..[query.length + 1] = 12 - ..[query.length + 2] = padding >> 8 - ..[query.length + 3] = padding & 0xff; - return List.unmodifiable(padded); -} - -/// Whether [uri] is a usable HTTPS endpoint: no credentials or fragment, a -/// host, and a port that parses and fits 1-65535. `Uri` accepts any digits as -/// a port and only throws when the port is read. -bool isUsableDohEndpoint(Uri uri) { - if (uri.scheme != 'https' || - uri.host.isEmpty || - uri.userInfo.isNotEmpty || - uri.hasFragment) { - return false; - } - try { - return uri.port >= 1 && uri.port <= 65535; - } on FormatException { - return false; - } -} - -List checkedDnsWireQuery(List query) { - final message = _parseOrNull(query); - if (message == null || message.isResponse) { - throw const DnsInvalidQueryException('Invalid DNS wire query'); - } - return List.unmodifiable(query); -} - -bool isSignedDnsWireQuery(List query) => - _parseOrNull(query)?.isSigned ?? false; - -_WireMessage? _parseOrNull(List bytes) { - try { - return _WireMessage.parse(bytes); - } on DnsException { - return null; - } -} - -class _Question { - final List name; - final int type; - final int dnsClass; - _Question(this.name, this.type, this.dnsClass); - - bool matches(_Question other) { - if (type != other.type || - dnsClass != other.dnsClass || - name.length != other.name.length) { - return false; - } - for (var i = 0; i < name.length; i++) { - if (name[i] != other.name[i]) return false; - } - return true; - } -} - -class _WireMessage { - final List data; - final questions = <_Question>[]; - // Pointer targets are the starts of fully decoded names and their label - // suffixes. Caching the decoded suffix makes each pointer O(1) instead of - // re-walking a chain the sender controls. - final _names = >{}; - int? optRdlengthOffset; - int? optVersion; - bool optIsLast = false; - bool optHasPadding = false; - bool isSigned = false; - final ttlOffsets = []; - int _offset = 12; - - _WireMessage._(this.data); - - int get id => _u16(0); - int get flags => _u16(2); - bool get isResponse => flags & 0x8000 != 0; - int get opcode => flags >> 11 & 0xf; - - factory _WireMessage.parse(List bytes) { - if (bytes.length < 12 || - bytes.length > 65535 || - bytes.any((byte) => byte < 0 || byte > 255)) { - throw _malformed; - } - final message = _WireMessage._(bytes); - message._parse(); - return message; - } - - int _u16(int offset) => data[offset] << 8 | data[offset + 1]; - - void _need(int count) { - if (_offset + count > data.length) throw _malformed; - } - - void _parse() { - final counts = [_u16(4), _u16(6), _u16(8), _u16(10)]; - for (var i = 0; i < counts[0]; i++) { - final name = _name(); - _need(4); - questions.add(_Question(name, _u16(_offset), _u16(_offset + 2))); - _offset += 4; - } - for (var section = 1; section < 4; section++) { - for (var i = 0; i < counts[section]; i++) { - _record(additional: section == 3); - } - } - if (_offset != data.length) throw _malformed; - } - - void _record({required bool additional}) { - final owner = _offset; - _name(); - _need(10); - final type = _u16(_offset); - final rdlength = _u16(_offset + 8); - final start = _offset + 10; - final end = start + rdlength; - if (end > data.length) throw _malformed; - if (type != 41) ttlOffsets.add(_offset + 4); - // SIG(0) and TSIG sign the header, including the ID. - if (additional && (type == 24 || type == 250)) isSigned = true; - if (type == 41) { - if (!additional || optRdlengthOffset != null || data[owner] != 0) { - throw _malformed; - } - optRdlengthOffset = _offset + 8; - optVersion = data[_offset + 5]; - for (var at = start; at < end;) { - if (at + 4 > end) throw _malformed; - if (_u16(at) == 12) optHasPadding = true; - at += 4 + _u16(at + 2); - if (at > end) throw _malformed; - } - } - _offset = start; - switch (type) { - case 2 || 3 || 4 || 5 || 7 || 8 || 9 || 12: - _name(limit: end); - case 14: - _name(limit: end); - _name(limit: end); - case 15 || 18 || 21: - _skip(2, end); - _name(limit: end); - case 17: - _name(limit: end); - _name(limit: end); - case 6: - _name(limit: end); - _name(limit: end); - _skip(20, end); - case 24: - _skip(18, end); - _name(limit: end); - if (_offset >= end) throw _malformed; - case 26: - _skip(2, end); - _name(limit: end); - _name(limit: end); - case 30: - _name(limit: end); - _skip(4, end); - case 33: - _skip(6, end); - _name(limit: end); - case 35: - _skip(4, end); - _characterString(end); - _characterString(end); - _characterString(end); - _name(limit: end); - case 36: - _skip(2, end); - _name(limit: end, compressed: false); - case 38: - _skip(1, end); - final prefixLength = data[_offset - 1]; - if (prefixLength > 128) throw _malformed; - _skip((128 - prefixLength + 7) ~/ 8, end); - if (prefixLength != 0) { - _name(limit: end, compressed: false); - } - case 39: - _name(limit: end, compressed: false); - case 46: - _skip(18, end); - _name(limit: end, compressed: false); - if (_offset >= end) throw _malformed; - case 47: - _name(limit: end, compressed: false); - // Newer types forbid compressing their own names, but a later name may - // still point into them, so each one is walked and registered. - case 55: - _skip(4, end); - final hitLength = data[_offset - 4]; - final keyLength = _u16(_offset - 2); - _skip(hitLength + keyLength, end); - while (_offset < end) { - _name(limit: end, compressed: false); - } - case 58: - _name(limit: end, compressed: false); - _name(limit: end, compressed: false); - case 64 || 65 || 107: - _skip(2, end); - _name(limit: end, compressed: false); - case 249 || 250: - _name(limit: end, compressed: false); - case 23: - _name(limit: end, compressed: false); - case 66: - _skip(5, end); - _name(limit: end, compressed: false); - case 45: - // Precedence, gateway type and algorithm, then the gateway. - _skip(3, end); - switch (data[_offset - 2]) { - case 1: - _skip(4, end); - case 2: - _skip(16, end); - case 3: - _name(limit: end, compressed: false); - } - case 260: - _skip(2, end); - switch (data[_offset - 1] & 0x7f) { - case 0: - break; - case 1: - _skip(4, end); - case 2: - _skip(16, end); - case 3: - _name(limit: end, compressed: false); - default: - _offset = end; - } - } - if (_offset > end || - ({ - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 12, - 14, - 15, - 17, - 18, - 21, - 26, - 33, - 35, - 36, - 38, - 23, - 39, - 55, - 58, - 66, - 107, - 260 - }.contains(type) && - _offset != end)) { - throw _malformed; - } - _offset = end; - optIsLast = type == 41; - } - - void _skip(int count, int limit) { - if (_offset + count > limit) throw _malformed; - _offset += count; - } - - void _characterString(int limit) { - _skip(1, limit); - _skip(data[_offset - 1], limit); - } - - // A pointer may only target a name decoded before this one began, so chains - // cannot loop and every jump costs one cached lookup. - List _name({int? limit, bool compressed = true}) { - final name = []; - final starts = <(int, int)>[]; - var at = _offset; - final bound = limit ?? data.length; - while (true) { - if (at >= bound) throw _malformed; - starts.add((at, name.length)); - final length = data[at]; - if (length == 0) { - name.add(0); - at++; - break; - } - switch (length & 0xc0) { - case 0xc0: - if (!compressed || at + 1 >= bound) throw _malformed; - final target = (length & 0x3f) << 8 | data[at + 1]; - final suffix = _names[target]; - if (target >= _offset || suffix == null) throw _malformed; - name.addAll(suffix); - at += 2; - case 0: - if (at + 1 + length > bound) throw _malformed; - name.add(length); - for (var i = at + 1; i <= at + length; i++) { - final c = data[i]; - name.add(c >= 0x41 && c <= 0x5a ? c + 32 : c); - } - if (name.length + 1 > 255) throw _malformed; - at += 1 + length; - continue; - default: - throw _malformed; - } - break; - } - if (name.length > 255) throw _malformed; - final decoded = Uint8List.fromList(name); - for (final (offset, index) in starts) { - // Pointer offsets are 14 bits; views share one buffer rather than - // allocating a list for every suffix of a long name. - if (offset < 0x4000) { - _names[offset] = Uint8List.sublistView(decoded, index); - } - } - _offset = at; - return decoded; - } -} diff --git a/.pubignore b/packages/doh_resolver/.pubignore similarity index 73% rename from .pubignore rename to packages/doh_resolver/.pubignore index 4427b81..5292b47 100644 --- a/.pubignore +++ b/packages/doh_resolver/.pubignore @@ -1,4 +1,7 @@ .dart_tool/ +.github/ +coverage/ pubspec.lock pubspec_overrides.yaml -coverage/ +test/ +tool/ diff --git a/packages/doh_resolver/CHANGELOG.md b/packages/doh_resolver/CHANGELOG.md new file mode 100644 index 0000000..5525c4d --- /dev/null +++ b/packages/doh_resolver/CHANGELOG.md @@ -0,0 +1,10 @@ +## 0.1.0 - unreleased + +- Breaking: `DnsAuthentication`, `DnsValidator`, `DnsValidationIssuer` and `issueTxt` are removed; results record only the resolver's AD assertion. +- Breaking: the transport, wire helpers and IO implementation moved to `doh_transport`, now a dependency. +- Breaking: `DnsException` is `doh_transport`'s open base class, so exhaustive switches over it no longer compile. +- Breaking: `AuthenticatedTxtResult` is renamed `ResolverAssertedTxtResult`; the old name is a deprecated typedef for one release. + +## 0.0.1 - unreleased + +- Initial JSON resolver: bounded TXT parsing, per-provider TXT decoding, strict CNAME traversal and resolver-asserted DNSSEC results. diff --git a/LICENSE b/packages/doh_resolver/LICENSE similarity index 100% rename from LICENSE rename to packages/doh_resolver/LICENSE diff --git a/packages/doh_resolver/README.md b/packages/doh_resolver/README.md new file mode 100644 index 0000000..8833f1b --- /dev/null +++ b/packages/doh_resolver/README.md @@ -0,0 +1,68 @@ +# doh_resolver + +TXT lookups through DNS-over-HTTPS JSON APIs that accept only answers the +resolver marks as DNSSEC-authenticated. + +## Usage + +```dart +import 'package:doh_resolver/doh_resolver_io.dart'; + +final transport = IoDohTransport(); +try { + final result = + await DohResolver(transport: transport).lookupTxt('example.com'); + for (final record in result.records) { + print(record.text); + } +} on DnsQueryFailedException catch (error) { + print('rcode ${error.rcode}'); +} on DnsException catch (error) { + print(error); +} finally { + await transport.close(); +} +``` + +## Trust model + +The resolver performs the DNSSEC validation; this package does not. An answer +is accepted only when the response has rcode 0, `AD` set, `CD` and `TC` clear +and a single TXT question for the normalized name. `ResolverAssertedTxtResult` +records that assertion and nothing more: a resolver that lies controls the +result. The transport verifies the endpoint certificate. The resolver sees the +queried name and type and, without a proxy, your address. For local validation +use `dnssec_resolver`. + +## Configuration + +- `endpoint`: a JSON API URI, `https://dns.google/resolve` by default. Google + and Cloudflare hosts select their `DnsTxtFormat`; any other endpoint must + pass `txtFormat`. +- `timeout` 12 s for a whole lookup; the transport's own deadline also applies. +- `maxResponseBytes` 65536. +- Proxy, fixed destination, connection reuse and transport deadlines are + `IoDohTransport` options; see `doh_transport`. + +Lookups send `do=true`, `cd=false` and `edns_client_subnet=0.0.0.0/0`, and pad +the URL to a multiple of 128 characters. `startTxtLookup` returns a +cancellable `DnsOperation` and never throws; every failure is a `DnsException`. + +## Development + +This repository is a pub workspace; the package is `packages/doh_resolver`. +`doh_transport` is not on pub.dev yet: clone it beside this repository and add +a root `pubspec_overrides.yaml`: + +```yaml +dependency_overrides: + doh_transport: + path: ../doh_transport +``` + +Then run `dart pub get` at the root and `dart tool/check.dart` from +`packages/doh_resolver`. Live resolver tests run with `DOH_LIVE_TESTS=1`. + +## License + +MIT; see `LICENSE`. diff --git a/analysis_options.yaml b/packages/doh_resolver/analysis_options.yaml similarity index 100% rename from analysis_options.yaml rename to packages/doh_resolver/analysis_options.yaml diff --git a/example/doh_resolver_example.dart b/packages/doh_resolver/example/doh_resolver_example.dart similarity index 100% rename from example/doh_resolver_example.dart rename to packages/doh_resolver/example/doh_resolver_example.dart diff --git a/packages/doh_resolver/lib/doh_resolver.dart b/packages/doh_resolver/lib/doh_resolver.dart new file mode 100644 index 0000000..7d9dae7 --- /dev/null +++ b/packages/doh_resolver/lib/doh_resolver.dart @@ -0,0 +1,26 @@ +/// Strict bounded TXT resolution through DNS over HTTPS JSON APIs. +library; + +export 'package:doh_transport/doh_transport.dart' + show + DnsException, + DnsInvalidNameException, + DnsInvalidQueryException, + DnsMalformedResponseException, + DnsResponseTooLargeException, + DnsQuestionMismatchException, + DnsTransportException, + DnsCancelledException, + DnsTimeoutException, + DnsOperation, + DohHttpResponse, + DohTransport, + DnsRecord, + DnsTxtRecord, + DohWireTransport, + normalizeDnsName, + effectiveDnsTtl, + isUsableDohEndpoint; + +export 'src/dns.dart'; +export 'src/resolver.dart'; diff --git a/packages/doh_resolver/lib/doh_resolver_io.dart b/packages/doh_resolver/lib/doh_resolver_io.dart new file mode 100644 index 0000000..87e1ebf --- /dev/null +++ b/packages/doh_resolver/lib/doh_resolver_io.dart @@ -0,0 +1,3 @@ +export 'package:doh_transport/doh_transport_io.dart' + show IoDohTransport, SocksProxy, SocksCredentials; +export 'doh_resolver.dart'; diff --git a/lib/src/dns.dart b/packages/doh_resolver/lib/src/dns.dart similarity index 71% rename from lib/src/dns.dart rename to packages/doh_resolver/lib/src/dns.dart index d2dff0a..001fecd 100644 --- a/lib/src/dns.dart +++ b/packages/doh_resolver/lib/src/dns.dart @@ -1,36 +1,6 @@ import 'dart:convert'; -import 'dart:math'; -/// Every failure this package reports. Switch over it exhaustively, or catch -/// a specific subtype. -sealed class DnsException implements Exception { - final String message; - const DnsException(this.message); - @override - String toString() => '$runtimeType: $message'; -} - -/// A name that is not an ASCII DNS name of at most 253 octets with -/// labels of letters, digits, `_` or `-`. -final class DnsInvalidNameException extends DnsException { - const DnsInvalidNameException(super.message); -} - -/// A caller-supplied wire query or request URI was unusable. -final class DnsInvalidQueryException extends DnsException { - const DnsInvalidQueryException(super.message); -} - -/// A response, or caller-supplied evidence, that does not parse or is -/// internally inconsistent. -final class DnsMalformedResponseException extends DnsException { - const DnsMalformedResponseException(super.message); -} - -/// A response that exceeded a configured size or count bound. -final class DnsResponseTooLargeException extends DnsException { - const DnsResponseTooLargeException(super.message); -} +import 'package:doh_transport/doh_transport.dart'; /// The resolver answered with a non-zero DNS RCODE, such as 3 (NXDOMAIN). final class DnsQueryFailedException extends DnsException { @@ -44,11 +14,6 @@ final class DnsAuthenticationUnavailableException extends DnsException { const DnsAuthenticationUnavailableException(super.message); } -/// A response, or CNAME chain, that does not answer the question asked. -final class DnsQuestionMismatchException extends DnsException { - const DnsQuestionMismatchException(super.message); -} - /// The answer's CNAME chain could not be followed to a single owner. sealed class DnsAliasException extends DnsException { const DnsAliasException(super.message); @@ -69,52 +34,6 @@ final class DnsAliasTooLongException extends DnsAliasException { const DnsAliasTooLongException(super.message); } -/// The HTTPS exchange failed; [httpStatus] is set when a non-200 status -/// was received. -final class DnsTransportException extends DnsException { - final int? httpStatus; - const DnsTransportException(super.message, {this.httpStatus}); -} - -/// An operation cancelled by its caller or by closing its transport. -final class DnsCancelledException extends DnsException { - const DnsCancelledException(super.message); -} - -/// An operation that reached its own deadline. Connection establishment -/// timeouts are reported as [DnsTransportException]. -final class DnsTimeoutException extends DnsException { - const DnsTimeoutException(super.message); -} - -final _label = RegExp(r'^[a-z0-9_-]+$'); - -/// The remaining lifetime of a received DNS [ttl], in seconds: zero when its -/// most significant bit is set (RFC 2181 §8), reduced by the HTTP cache [age] -/// (RFC 8484 §5.1), and never negative. -int effectiveDnsTtl(int ttl, {Duration age = Duration.zero}) { - if (ttl < 0 || ttl > 0xffffffff) throw ArgumentError.value(ttl, 'ttl'); - if (age < Duration.zero) throw ArgumentError.value(age, 'age'); - return max(0, (ttl > 0x7fffffff ? 0 : ttl) - age.inSeconds); -} - -/// Lower-cases [name], strips one trailing dot, and checks it is a valid -/// ASCII DNS name; throws [DnsInvalidNameException] otherwise. -String normalizeDnsName(String name) { - if (name.codeUnits.any((c) => c > 127)) { - throw const DnsInvalidNameException("Use ASCII DNS names"); - } - final value = (name.endsWith('.') ? name.substring(0, name.length - 1) : name) - .toLowerCase(); - if (value.isEmpty || - value.length > 253 || - !value.split('.').every((label) => - label.isNotEmpty && label.length <= 63 && _label.hasMatch(label))) { - throw const DnsInvalidNameException('Invalid ASCII DNS name'); - } - return value; -} - /// A question from a JSON DNS response. class DnsQuestion { final String name; @@ -122,44 +41,6 @@ class DnsQuestion { const DnsQuestion(this.name, this.type); } -/// A resource record from a JSON DNS response, in the resolver's -/// presentation format. Parsed records hold an [effectiveDnsTtl]. -class DnsRecord { - final String name; - final int type; - final int ttl; - final String data; - const DnsRecord( - {required this.name, - required this.type, - required this.ttl, - required this.data}); -} - -/// The octets of one TXT RRset member, with its character-strings joined. -class DnsTxtRecord { - final String name; - final int ttl; - final List bytes; - DnsTxtRecord( - {required this.name, required this.ttl, required List bytes}) - : bytes = List.unmodifiable(bytes) { - if (this.bytes.any((byte) => byte < 0 || byte > 255)) { - throw ArgumentError.value(bytes, 'bytes', 'TXT data must be octets'); - } - } - - /// [bytes] decoded as UTF-8; throws [DnsMalformedResponseException] if - /// they are not valid UTF-8. - String get text { - try { - return utf8.decode(bytes); - } on FormatException { - throw const DnsMalformedResponseException('TXT is not UTF-8'); - } - } -} - /// TXT records from an AD-flagged resolver response. An empty [records] list is /// a NODATA answer the resolver asserts is authenticated. /// diff --git a/lib/src/resolver.dart b/packages/doh_resolver/lib/src/resolver.dart similarity index 74% rename from lib/src/resolver.dart rename to packages/doh_resolver/lib/src/resolver.dart index 1154bfa..84aab65 100644 --- a/lib/src/resolver.dart +++ b/packages/doh_resolver/lib/src/resolver.dart @@ -1,59 +1,9 @@ import 'dart:async'; import 'dart:convert'; -import 'dns.dart'; -import 'wire.dart'; - -/// A started request. Starting never throws: every failure, including -/// invalid input, completes [result] with a [DnsException]. -class DnsOperation { - /// Completes with the outcome. Like any [Future], an error nobody listens - /// to is reported as uncaught; cancelling does not consume it, so attach a - /// handler or call [FutureExtensions.ignore] on results you discard. - final Future result; - - /// Fails [result] with [DnsCancelledException] unless it has already - /// completed, and releases the work. Safe to call repeatedly. - final void Function() cancel; - const DnsOperation(this.result, this.cancel); - - /// An operation that has already failed with [error]. - DnsOperation.failed(DnsException error) : this(Future.error(error), _noop); -} - -void _noop() {} - -/// A complete HTTPS response from a DoH resolver. -class DohHttpResponse { - final int statusCode; - final List body; - /// Time the response spent in HTTP caches, from its `Age` header. DNS TTLs - /// must be reduced by it; see [effectiveDnsTtl] and [adjustDnsWireTtls]. - final Duration age; - DohHttpResponse(this.statusCode, List body, {this.age = Duration.zero}) - : body = List.unmodifiable(body) { - if (age < Duration.zero) throw ArgumentError.value(age, 'age'); - } -} +import 'package:doh_transport/doh_transport.dart'; -/// Sends DoH JSON GET requests. -/// -/// Implementations must deliver every failure through the returned -/// [DnsOperation.result], bound the body to `maxResponseBytes`, and make -/// `cancel` idempotent. [DohResolver] cancels only operations that have not -/// completed. -abstract interface class DohTransport { - DnsOperation start(Uri uri, {required int maxResponseBytes}); -} - -/// Sends RFC 8484 wire-format queries; see [DohTransport] for the contract. -abstract interface class DohWireTransport { - /// Sends wire [query]. With [preserveMessage], the query is sent and the - /// response returned exactly as exchanged, with no padding or ID rewrite, - /// for transaction signatures such as SIG(0) that cover both messages. - DnsOperation startWire(Uri uri, List query, - {required int maxResponseBytes, bool preserveMessage = false}); -} +import 'dns.dart'; /// Looks up TXT records through a DoH JSON API and accepts only answers the /// resolver asserts are DNSSEC-authenticated. diff --git a/packages/doh_resolver/pubspec.yaml b/packages/doh_resolver/pubspec.yaml new file mode 100644 index 0000000..5bc177f --- /dev/null +++ b/packages/doh_resolver/pubspec.yaml @@ -0,0 +1,12 @@ +name: doh_resolver +description: Strict bounded TXT resolution through DNS over HTTPS JSON APIs. +version: 0.1.0 +repository: https://github.com/cypherstack/doh_resolver +environment: + sdk: ">=3.5.0 <4.0.0" +resolution: workspace +dependencies: + doh_transport: ^0.0.1 +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.0 diff --git a/test/dns_test.dart b/packages/doh_resolver/test/dns_test.dart similarity index 92% rename from test/dns_test.dart rename to packages/doh_resolver/test/dns_test.dart index e829c15..075aeb3 100644 --- a/test/dns_test.dart +++ b/packages/doh_resolver/test/dns_test.dart @@ -40,7 +40,7 @@ class FakeTransport implements DohTransport { } void main() { - test('only terminal-owner TXT answers are accepted; evidence retained', () { + test('only terminal-owner TXT answers are accepted; response retained', () { final response = decode(reply(answers: [ rr('alice.example.', 5, 'pay.example.'), rr('pay.example.', 16, 'one'), @@ -86,22 +86,10 @@ void main() { throwsA(isA() .having((e) => e.httpStatus, 'httpStatus', 503))); }); - test('the exception hierarchy is sealed and exhaustive', () { - String describe(DnsException error) => switch (error) { - DnsInvalidNameException() => 'name', - DnsInvalidQueryException() => 'query', - DnsMalformedResponseException() => 'malformed', - DnsResponseTooLargeException() => 'large', - DnsQueryFailedException(:final rcode) => 'rcode $rcode', - DnsAuthenticationUnavailableException() => 'unauthenticated', - DnsQuestionMismatchException() => 'mismatch', - DnsAliasException() => 'alias', - DnsTransportException(:final httpStatus) => 'http $httpStatus', - DnsCancelledException() => 'cancelled', - DnsTimeoutException() => 'timeout', - }; - expect(describe(const DnsAliasLoopException('loop')), 'alias'); - expect(describe(const DnsQueryFailedException('x', rcode: 3)), 'rcode 3'); + test('the open exception root catches transport and resolution leaves', () { + expect(const DnsAliasLoopException('loop'), isA()); + expect(const DnsTimeoutException('late'), isA()); + expect(const DnsQueryFailedException('x', rcode: 3).rcode, 3); expect(const DnsTimeoutException('late').toString(), 'DnsTimeoutException: late'); }); @@ -283,19 +271,8 @@ void main() { expect(() => decodeDnsTxtBytes('a' * 8193, DnsTxtFormat.quoted), throwsA(isA())); }); - test('DNS names allow service labels without permitting alias syntax', () { - expect(normalizeDnsName('_Dmarc.Example.'), '_dmarc.example'); - for (final name in [ - 'a@b.example', - 'K.example', - 'a..example', - 'https://example.org' - ]) { - expect(() => normalizeDnsName(name), - throwsA(isA())); - } - }); - test('requests privacy flags and propagates evidence', () async { + test('requests privacy flags and propagates the authenticated result', + () async { final transport = FakeTransport(); final resolver = DohResolver(transport: transport); final query = resolver.startTxtLookup('alice.example'); diff --git a/test/example_test.dart b/packages/doh_resolver/test/example_test.dart similarity index 100% rename from test/example_test.dart rename to packages/doh_resolver/test/example_test.dart diff --git a/packages/doh_resolver/test/live_test.dart b/packages/doh_resolver/test/live_test.dart new file mode 100644 index 0000000..de84328 --- /dev/null +++ b/packages/doh_resolver/test/live_test.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:doh_resolver/doh_resolver_io.dart'; +import 'package:test/test.dart'; + +final skip = Platform.environment['DOH_LIVE_TESTS'] == '1' + ? null + : 'Set DOH_LIVE_TESTS=1 to query public resolvers'; + +void main() { + for (final endpoint in [ + Uri.https('dns.google', '/resolve'), + Uri.https('cloudflare-dns.com', '/dns-query'), + ]) { + test('${endpoint.host} JSON resolver-asserted TXT', () async { + final transport = IoDohTransport(); + addTearDown(transport.close); + final result = await DohResolver(transport: transport, endpoint: endpoint) + .lookupTxt('getmonero.org'); + expect(result.records, isNotEmpty); + }, skip: skip); + } + + // The DKIM key is a CNAME to a TXT record of several character-strings, + // so it covers chunk joining in both provider encodings. + for (final domain in ['getmonero.org', 'protonmail._domainkey.proton.me']) { + test('Google raw and Cloudflare quoted TXT decode $domain identically', + () async { + Future> texts(Uri endpoint) async { + final transport = IoDohTransport(); + addTearDown(transport.close); + return { + for (final record + in (await DohResolver(transport: transport, endpoint: endpoint) + .lookupTxt(domain)) + .records) + record.text + }; + } + + final google = await texts(Uri.https('dns.google', '/resolve')); + expect( + google, await texts(Uri.https('cloudflare-dns.com', '/dns-query'))); + if (domain.contains('_domainkey')) { + expect(google.single.length, greaterThan(255)); + } + }, skip: skip); + } +} diff --git a/tool/check.dart b/packages/doh_resolver/tool/check.dart similarity index 100% rename from tool/check.dart rename to packages/doh_resolver/tool/check.dart diff --git a/pubspec.yaml b/pubspec.yaml index bbf5d11..9a03204 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,6 @@ -name: doh_resolver -description: Bounded DNS over HTTPS JSON and wire queries with explicit transport, authentication evidence, and strict TXT resolution. -version: 0.0.1 -repository: https://github.com/cypherstack/doh_resolver +name: doh_resolver_workspace +publish_to: none environment: sdk: ">=3.5.0 <4.0.0" -dependencies: - socks_socket: ^2.0.0 -dev_dependencies: - lints: ^5.0.0 - pointycastle: ^4.0.0 - test: ^1.25.0 +workspace: + - packages/doh_resolver diff --git a/test/compression_budget_test.dart b/test/compression_budget_test.dart deleted file mode 100644 index a70ba89..0000000 --- a/test/compression_budget_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:collection'; - -import 'package:doh_resolver/doh_resolver.dart'; -import 'package:test/test.dart'; - -import 'support/dns_wire.dart'; - -// Count byte reads instead of timing: expose quadratic decompression reliably. -class CountingBytes extends ListBase { - final List bytes; - int reads = 0; - CountingBytes(this.bytes); - @override - int get length => bytes.length; - @override - set length(int value) => throw UnsupportedError('immutable'); - @override - int operator [](int index) { - reads++; - return bytes[index]; - } - - @override - void operator []=(int index, int value) => - throw UnsupportedError('immutable'); -} - -void main() { - test('backward pointer chains take bounded linear work', () { - // DNS compression offsets are 14 bits. Build a chain entirely below 16 KiB, - // then refer to its tail for the remainder of a near-maximum-size message. - final records = >[]; - var previous = 12; - var offset = 12 + wireQuestion(wireName('alice.example')).length; - for (var i = 0; i < 5000; i++) { - final record = - wireRecord([0xc0 | (previous >> 8), previous & 255], 99, []); - records.add(record); - if (offset < 16384) previous = offset; - offset += record.length; - } - final response = CountingBytes(wireReply(answers: records)); - validateDnsWireResponse(wireQuery(), response); - expect(response.reads, lessThan(response.length * 30)); - }); -} diff --git a/test/io_connection_reuse_test.dart b/test/io_connection_reuse_test.dart deleted file mode 100644 index d7b826a..0000000 --- a/test/io_connection_reuse_test.dart +++ /dev/null @@ -1,506 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:doh_resolver/doh_resolver_io.dart'; -import 'package:test/test.dart'; - -import 'support/dns_wire.dart'; -import 'support/scripted_server.dart'; -import 'support/test_pki.dart'; -import 'support/tunnel_proxy.dart'; - -void main() { - late TestPki pki; - late ScriptedServer server; - setUpAll(() => pki = TestPki.generate()); - tearDown(() => server.close()); - - Future serve(FutureOr Function(ScriptedExchange) handler) async { - server = ScriptedServer(pki.serverContext(), handler); - await server.start(); - } - - void answer(ScriptedExchange e) => e.send(httpResponse(wireReply(id: e.id))); - - Future tunnel(bool proxied) async { - if (!proxied) return null; - final proxy = TunnelProxy(upstreamPort: server.port); - await proxy.start(); - addTearDown(proxy.close); - return proxy; - } - - IoDohTransport transport(TunnelProxy? proxy, - {bool reuseConnections = true, - int maxConcurrentRequests = 8, - Duration requestTimeout = const Duration(seconds: 3), - Duration idleTimeout = const Duration(seconds: 5)}) { - final transport = IoDohTransport( - securityContext: pki.clientContext(), - proxy: proxy == null - ? null - : SocksProxy(InternetAddress.loopbackIPv4, proxy.port, - credentials: SocksCredentials.isolation('lookup')), - reuseConnections: reuseConnections, - maxConcurrentRequests: maxConcurrentRequests, - requestTimeout: requestTimeout, - idleTimeout: idleTimeout); - addTearDown(transport.close); - return transport; - } - - DnsOperation start(IoDohTransport transport, int id, - {String name = 'alice.example'}) => - transport.startWire( - Uri.parse('https://localhost:${server.port}/dns-query'), - wireQuery(id: id, name: name), - maxResponseBytes: 512); - - Future until(bool Function() condition) async { - for (var i = 0; i < 300 && !condition(); i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - expect(condition(), isTrue); - } - - Future expectAnswer(IoDohTransport transport, int id) async { - final response = await start(transport, id).result; - expect(response.body.sublist(0, 2), u16(id)); - } - - for (final proxied in [false, true]) { - final mode = proxied ? 'SOCKS' : 'direct'; - for (final reuse in [true, false]) { - test( - 'sequential $mode requests open ${reuse ? 'one connection' : 'one ' - 'connection each'} with reuse ${reuse ? 'on' : 'off'}', () async { - await serve(answer); - final proxy = await tunnel(proxied); - final t = transport(proxy, reuseConnections: reuse); - for (var id = 1; id <= 4; id++) { - await expectAnswer(t, id); - } - expect(server.connections, reuse ? 1 : 4); - if (proxy != null) { - expect(proxy.connections, reuse ? 1 : 4); - expect(proxy.username, 'lookup'); - expect(proxy.targetType, 3); - expect(proxy.target, 'localhost'); - } - }); - } - - for (final ( - name, - code, - cancel, - Future Function(ScriptedExchange, Completer) first - ) in [ - ( - 'an empty-body 404', - isA(), - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse([], status: 404)) - ), - ( - 'a JSON-typed reply', - isA(), - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse(wireReply(id: e.id), type: 'application/json')) - ), - ( - 'a buffered reply with another ID', - isA(), - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse(wireReply(id: e.id ^ 1))) - ), - ( - 'a chunked reply with another ID', - isA(), - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse(wireReply(id: e.id ^ 1), chunked: true)) - ), - ( - 'a chunked reply over the size limit', - isA(), - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse(List.filled(600, 0), chunked: true)) - ), - ( - 'a timed-out exchange answered late with an extra response', - isA(), - false, - (ScriptedExchange e, Completer _) async { - await Future.delayed(const Duration(milliseconds: 500)); - answer(e); - answer(e); - } - ), - ( - 'a cancelled exchange answered late with an extra response', - isA(), - true, - (ScriptedExchange e, Completer ready) async { - ready.complete(); - await Future.delayed(const Duration(milliseconds: 100)); - answer(e); - answer(e); - } - ), - ( - 'a reply cancelled mid-body and then completed with an extra response', - isA(), - true, - (ScriptedExchange e, Completer ready) async { - final reply = httpResponse(wireReply(id: e.id)); - e.send(reply.sublist(0, reply.length - 4)); - await e.socket.flush(); - ready.complete(); - await Future.delayed(const Duration(milliseconds: 100)); - e.send(reply.sublist(reply.length - 4)); - answer(e); - } - ), - ]) { - test('$mode: $name never reaches the next request', () async { - final ready = Completer(); - await serve((e) => e.index == 1 ? first(e, ready) : answer(e)); - final proxy = await tunnel(proxied); - final t = - transport(proxy, requestTimeout: const Duration(milliseconds: 300)); - final operation = start(t, 0x1111); - final failed = expectLater(operation.result, throwsA(code)); - if (cancel) { - await ready.future; - operation.cancel(); - } - await failed; - await until(() => server.closed == 1); - await Future.delayed(const Duration(milliseconds: 300)); - await expectAnswer(t, 0x4242); - expect(server.connections, 2); - expect(server.exchanges.last.connection, 2); - if (proxy != null) expect(proxy.connections, 2); - }); - } - - test('$mode: cancelling one request leaves other lanes untouched', - () async { - await serve((e) { - if (e.index > 2) answer(e); - }); - final proxy = await tunnel(proxied); - final t = transport(proxy, maxConcurrentRequests: 2); - final a = start(t, 1, name: 'a.example'); - final b = start(t, 2, name: 'b.example'); - final aFailed = - expectLater(a.result, throwsA(isA())); - await until(() => server.exchanges.length == 2); - final held = - server.exchanges.firstWhere((e) => wireFirstLabel(e.body) == 'b'); - a.cancel(); - await aFailed; - await until(() => server.closed == 1); - held.send(httpResponse(wireEcho(held.body))); - expect((await b.result).body.sublist(0, 2), u16(2)); - await expectAnswer(t, 3); - expect(server.connections, 2); - expect(server.exchanges.last.connection, held.connection); - expect(server.closed, 1); - }); - } - - for (final proxied in [false, true]) { - test( - 'reuse defaults ${proxied ? 'off with' : 'on without'} a proxy, ' - 'so proxied lookups are not linked', () async { - await serve(answer); - final proxy = await tunnel(proxied); - final t = IoDohTransport( - securityContext: pki.clientContext(), - proxy: proxy == null - ? null - : SocksProxy(InternetAddress.loopbackIPv4, proxy.port)); - addTearDown(t.close); - expect(t.reuseConnections, !proxied); - for (var id = 1; id <= 3; id++) { - await expectAnswer(t, id); - } - expect(server.connections, proxied ? 3 : 1); - if (proxy != null) expect(proxy.connections, 3); - }); - } - - for (final (name, type) in [('SIG(0)', 24), ('TSIG', 250)]) { - test('$name-signed queries keep their exact bytes and ID', () async { - await serve(answer); - final t = transport(null); - Future send(int id) async { - final query = wireSignedQuery(id: id, type: type); - final response = await t - .startWire( - Uri.parse('https://localhost:${server.port}/dns-query'), query, - maxResponseBytes: 512) - .result; - expect(server.exchanges.last.body, query, - reason: 'the signature covers the header and every record'); - expect(response.body.sublist(0, 2), u16(id)); - } - - await send(0x5150); - await send(0x5150); - expect(server.connections, 2, - reason: 'a preserved ID is still never repeated on one connection'); - await send(0x5151); - expect(server.connections, 2); - }); - } - - test('preserveMessage exchanges an unsigned query byte-for-byte', () async { - // A SIG(0)-signed response covers its header and the request as sent. - final served = >[]; - await serve((e) { - // The echoed OPT plus a stand-in SIG(0) record. - final signed = [ - ...wireEcho(e.body)..[11] = 2, - ...wireRecord([0], 24, [...List.filled(18, 0), 0, 9]), - ]; - served.add(signed); - e.send(httpResponse(signed)); - }); - final t = transport(null); - final query = wireQuery(id: 0x0bad); - Future send() => t - .startWire( - Uri.parse('https://localhost:${server.port}/dns-query'), query, - maxResponseBytes: 512, preserveMessage: true) - .result; - - final response = await send(); - expect(server.exchanges.last.body, query, - reason: 'no padding and no ID rewrite'); - expect(response.body, served.last, - reason: 'the response is returned exactly as signed'); - await send(); - expect(server.connections, 2, - reason: 'a preserved ID is still never repeated on one connection'); - }); - - test('an unsolicited response on an idle lane retires only that lane', - () async { - await serve((e) async { - answer(e); - if (e.index == 1) { - await e.socket.flush(); - await Future.delayed(const Duration(milliseconds: 50)); - answer(e); - } - }); - final t = transport(null); - await expectAnswer(t, 1); - await until(() => server.closed == 1); - await expectAnswer(t, 2).timeout(const Duration(seconds: 2)); - expect(server.connections, 2); - expect(server.exchanges.last.connection, 2); - }); - - test('JSON requests never consume a duplicate response from a prior request', - () async { - List response(String value) => httpResponse( - utf8.encode(jsonEncode({ - 'Status': 0, - 'AD': true, - 'CD': false, - 'TC': false, - 'Question': [ - {'name': 'alice.example.', 'type': 16} - ], - 'Answer': [ - {'name': 'alice.example.', 'type': 16, 'TTL': 60, 'data': value} - ] - })), - type: 'application/json'); - await serve((e) async { - if (e.index == 1) { - e.send(response('old')); - await e.socket.flush(); - await Future.delayed(const Duration(milliseconds: 10)); - e.send(response('old')); - } else { - await Future.delayed(const Duration(milliseconds: 100)); - e.send(response('new')); - } - }); - final t = transport(null); - final resolver = DohResolver( - txtFormat: DnsTxtFormat.raw, - transport: t, - endpoint: Uri.parse('https://localhost:${server.port}/resolve')); - expect( - (await resolver.lookupTxt('alice.example')).records.single.text, 'old'); - expect( - (await resolver.lookupTxt('alice.example')).records.single.text, 'new'); - expect(server.connections, 2); - }); - - test('repeated caller IDs reuse one connection with distinct wire IDs', - () async { - await serve(answer); - final t = transport(null); - for (var i = 0; i < 4; i++) { - await expectAnswer(t, 0); - } - expect(server.connections, 1); - expect(server.exchanges.map((e) => e.id).toSet(), hasLength(4)); - }); - - test('a stale duplicate reply never answers the next request', () async { - await serve((e) async { - if (e.index == 1) { - e.send(httpResponse(wireReply(id: e.id, flags: 0x8183))); - await e.socket.flush(); - await Future.delayed(const Duration(milliseconds: 10)); - e.send(httpResponse(wireReply(id: e.id, flags: 0x8183))); - } else { - await Future.delayed(const Duration(milliseconds: 100)); - answer(e); - } - }); - final t = transport(null); - expect((await start(t, 0).result).body[3] & 0xf, 3); - // The duplicate is either seen on the idle connection, which is retired, - // or consumed by the next request, whose wire ID it fails to match. - final next = await start(t, 0) - .result - .then((r) => r, onError: (Object e) => e); - if (next is DohHttpResponse) { - expect(next.body[3] & 0xf, 0); - expect(server.connections, 2); - } else { - expect(next, isA()); - } - }); - - test('a connection closed by the server while idle is not reused', () async { - await serve((e) async { - answer(e); - await e.socket.flush(); - await e.socket.close(); - }); - final t = transport(null); - await expectAnswer(t, 1); - await Future.delayed(const Duration(milliseconds: 100)); - await expectAnswer(t, 2); - expect(server.connections, 2); - }); - - test('lanes never exceed maxConcurrentRequests', () async { - await serve(answer); - final t = transport(null, maxConcurrentRequests: 2); - await Future.wait([for (var id = 1; id <= 6; id++) expectAnswer(t, id)]); - expect(server.connections, lessThanOrEqualTo(2)); - }); - - test('a reusable lane is pinned to one HTTPS origin', () async { - await serve(answer); - final other = ScriptedServer(pki.serverContext(), answer); - await other.start(); - addTearDown(other.close); - final t = transport(null, maxConcurrentRequests: 1); - DnsOperation startAt(ScriptedServer target, int id) => - t.startWire(Uri.parse('https://localhost:${target.port}/dns-query'), - wireQuery(id: id), - maxResponseBytes: 512); - - await startAt(server, 1).result; - await startAt(other, 2).result; - await startAt(server, 3).result; - expect(server.connections, 2, - reason: 'returning to an origin must not revive its retired pool'); - expect(other.connections, 1); - }); - - test('queued handoffs do not chain lane zones', () async { - await serve(answer); - final overrides = _ZoneRecordingOverrides(); - final lanes = await HttpOverrides.runWithHttpOverrides(() async { - final t = - transport(null, reuseConnections: false, maxConcurrentRequests: 1); - await Future.wait([for (var id = 1; id <= 4; id++) expectAnswer(t, id)]); - return overrides.zones; - }, overrides); - expect(lanes, hasLength(4)); - expect(lanes.map((zone) => zone.parent).toSet(), hasLength(1), - reason: 'each lane must fork from the transport zone, not its ' - 'predecessor, or retired lanes stay reachable'); - }); - - test('idle connections are released without close', () async { - await serve(answer); - final t = transport(null, idleTimeout: const Duration(milliseconds: 200)); - await expectAnswer(t, 1); - expect(server.closed, 0); - await until(() => server.closed == 1); - await expectAnswer(t, 2); - expect(server.connections, 2); - }); - - test('close fails queued and in-flight requests and later starts', () async { - await serve((e) { - if (e.index == 1) answer(e); - }); - final t = transport(null, maxConcurrentRequests: 1); - await expectAnswer(t, 1); - final inFlight = start(t, 2); - final queued = start(t, 3); - final failed = [ - for (final operation in [inFlight, queued]) - expectLater(operation.result, throwsA(isA())) - ]; - await until(() => server.exchanges.length == 2); - await t.close(); - await Future.wait(failed); - await until(() => server.closed == 1); - await expectLater( - start(t, 4).result, throwsA(isA())); - expect(server.connections, 1); - }); - - test('close cancels a pending SOCKS connection', () async { - await serve(answer); - final proxy = TunnelProxy(upstreamPort: server.port, stallAt: 1); - await proxy.start(); - addTearDown(proxy.close); - final t = transport(proxy); - final failed = - expectLater(start(t, 1).result, throwsA(isA())); - await proxy.connected.future; - await t.close(); - await failed; - await proxy.disconnected.future.timeout(const Duration(seconds: 2)); - expect(server.connections, 0); - }); - - test('close releases idle connections', () async { - await serve(answer); - final t = transport(null); - await expectAnswer(t, 1); - await t.close(); - await until(() => server.closed == 1); - }); -} - -class _ZoneRecordingOverrides extends HttpOverrides { - final zones = []; - @override - HttpClient createHttpClient(SecurityContext? context) { - zones.add(Zone.current); - return super.createHttpClient(context); - } -} diff --git a/test/io_transport_test.dart b/test/io_transport_test.dart deleted file mode 100644 index 4811378..0000000 --- a/test/io_transport_test.dart +++ /dev/null @@ -1,277 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:doh_resolver/doh_resolver_io.dart'; -import 'package:test/test.dart'; -import 'dns_test.dart' show reply; -import 'support/test_pki.dart'; -import 'support/tunnel_proxy.dart'; - -void main() { - late HttpServer server; - late SecurityContext trust; - late TestPki pki; - setUpAll(() => pki = TestPki.generate()); - setUp(() async { - trust = pki.clientContext(); - server = await HttpServer.bindSecure( - InternetAddress.loopbackIPv4, 0, pki.serverContext()); - }); - tearDown(() => server.close(force: true)); - Uri endpoint() => Uri.parse('https://localhost:${server.port}/resolve'); - - for (final proxied in [false, true]) { - test('authenticated TXT over ${proxied ? 'SOCKS' : 'direct'} HTTPS', - () async { - var requests = 0; - server.listen((request) { - requests++; - expect(request.uri.queryParameters['name'], 'alice.example.'); - expect(request.uri.queryParameters['do'], 'true'); - expect(request.uri.queryParameters['cd'], 'false'); - expect(request.uri.queryParameters['edns_client_subnet'], '0.0.0.0/0'); - expect(request.headers.value('accept'), 'application/dns-json'); - request.response.headers.contentType = ContentType.json; - request.response.write(jsonEncode(reply())); - request.response.close(); - }); - final proxy = TunnelProxy(upstreamPort: server.port, fragmented: true); - if (proxied) { - await proxy.start(); - addTearDown(proxy.close); - } - final transport = IoDohTransport( - securityContext: trust, - proxy: proxied - ? SocksProxy(InternetAddress.loopbackIPv4, proxy.port) - : null, - proxyDestination: proxied ? InternetAddress('8.8.8.8') : null); - final resolver = DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - transport: transport); - final result = await resolver.lookupTxt('alice.example'); - expect(result.records.single.text, 'oa1:xmr recipient_address=valid;'); - expect(result.resolver, endpoint()); - expect(requests, 1); - if (proxied) { - expect(proxy.target, '8.8.8.8'); - await transport.close(); - await proxy.disconnected.future.timeout(const Duration(seconds: 2)); - } - }); - } - test('a rejected required proxy never falls back to reachable direct HTTPS', - () async { - var requests = 0; - server.listen((request) { - requests++; - request.response.close(); - }); - final proxy = TunnelProxy(replyCode: 5); - await proxy.start(); - addTearDown(proxy.close); - final resolver = DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - transport: IoDohTransport( - securityContext: trust, - proxy: SocksProxy(InternetAddress.loopbackIPv4, proxy.port))); - await expectLater(resolver.lookupTxt('alice.example'), - throwsA(isA())); - await Future.delayed(const Duration(milliseconds: 50)); - expect(requests, 0); - expect(proxy.connections, 1); - }); - test('HTTPS identity must be trusted', () async { - server.listen((r) => r.response.close(), onError: (Object _) {}); - await expectLater( - DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - transport: IoDohTransport()) - .lookupTxt('alice.example'), - throwsA(isA())); - }); - test('redirects never produce a second request', () async { - var requests = 0; - server.listen((r) { - requests++; - r.response.statusCode = 302; - r.response.headers - .set('location', endpoint().replace(path: '/redirected').toString()); - r.response.close(); - }); - await expectLater( - DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(isA())); - expect(requests, 1); - }); - test('streaming response size limit aborts before parsing', () async { - server.listen((r) { - r.response.headers.contentType = ContentType.json; - r.response.write('x' * 129); - r.response.close(); - }); - await expectLater( - DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - maxResponseBytes: 128, - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(isA())); - }); - test('unsigned response is not accepted merely because HTTPS succeeded', - () async { - server.listen((r) { - r.response.headers.contentType = ContentType.json; - r.response.write(jsonEncode(reply()..['AD'] = false)); - r.response.close(); - }); - await expectLater( - DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(isA())); - }); - for (final cancel in [false, true]) { - test('${cancel ? 'cancel' : 'timeout'} destroys stalled response transport', - () async { - final sent = Completer(); - server.listen((r) async { - r.response.headers.contentType = ContentType.json; - r.response.write('{'); - await r.response.flush(); - if (!sent.isCompleted) sent.complete(); - }); - final proxy = TunnelProxy(upstreamPort: server.port); - await proxy.start(); - addTearDown(proxy.close); - final resolver = DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - timeout: const Duration(milliseconds: 300), - transport: IoDohTransport( - securityContext: trust, - proxy: SocksProxy(InternetAddress.loopbackIPv4, proxy.port))); - final operation = resolver.startTxtLookup('alice.example'); - final failed = expectLater( - operation.result, - throwsA(cancel - ? isA() - : isA())); - await sent.future; - if (cancel) operation.cancel(); - await failed; - await proxy.disconnected.future.timeout(const Duration(seconds: 2)); - }); - } - test('generated test certificate is a short-lived serverAuth leaf', () async { - server.listen((r) => r.response.close()); - final socket = - await SecureSocket.connect('localhost', server.port, context: trust); - final leaf = socket.peerCertificate!; - socket.destroy(); - final now = DateTime.now(); - expect(leaf.issuer, contains('doh_resolver test CA')); - expect(leaf.subject, isNot(leaf.issuer)); - expect(leaf.startValidity.isBefore(now), isTrue); - expect(leaf.endValidity.difference(leaf.startValidity).inDays, - lessThanOrEqualTo(31)); - await expectLater( - SecureSocket.connect('localhost', server.port, - context: TestPki.generate().clientContext()), - throwsA(isA())); - }); - test('requests identity encoding without a client fingerprint', () async { - server.listen((r) { - expect(r.headers[HttpHeaders.acceptEncodingHeader], isNull); - expect(r.headers[HttpHeaders.userAgentHeader], isNull); - r.response.headers.contentType = ContentType.json; - r.response.write(jsonEncode(reply())); - r.response.close(); - }); - final result = await DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'); - expect(result.records, hasLength(1)); - }); - for (final (header, seconds) in [ - (null, 0), - ('250', 250), - (' 7 ', 7), - ('000000000001', 1), - ('00000000000000000000', 0), - ('2147483648', 0x80000000), - ('99999999999', 0x80000000), - ('000099999999999', 0x80000000), - ('250, 0', 250), - ('5,abc', 5), - (', 5', 0), - ('-5', 0), - ('1.5', 0), - ('abc', 0), - ]) { - test('reports HTTP Age header $header as $seconds seconds', () async { - server.listen((r) { - r.response.headers.contentType = ContentType.json; - if (header != null) { - r.response.headers.set(HttpHeaders.ageHeader, header); - } - r.response.write(jsonEncode(reply())); - r.response.close(); - }); - final response = await IoDohTransport(securityContext: trust) - .start(endpoint(), maxResponseBytes: 65536) - .result; - expect(response.age, Duration(seconds: seconds)); - }); - } - test('uses the first of repeated HTTP Age headers', () async { - server.listen((r) { - r.response.headers - ..contentType = ContentType.json - ..add(HttpHeaders.ageHeader, '250') - ..add(HttpHeaders.ageHeader, '0'); - r.response.write(jsonEncode(reply())); - r.response.close(); - }); - final response = await IoDohTransport(securityContext: trust) - .start(endpoint(), maxResponseBytes: 65536) - .result; - expect(response.age, const Duration(seconds: 250)); - }); - for (final (type, encoding) in [ - (null, null), - (ContentType.text, null), - (ContentType.html, null), - (ContentType.json, 'gzip'), - ]) { - test('rejects JSON body typed $type encoded $encoding', () async { - server.listen((r) { - if (type != null) r.response.headers.contentType = type; - if (encoding != null) { - r.response.headers.set(HttpHeaders.contentEncodingHeader, encoding); - } - r.response.write(jsonEncode(reply())); - r.response.close(); - }); - await expectLater( - DohResolver( - txtFormat: DnsTxtFormat.raw, - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(isA())); - }); - } -} diff --git a/test/io_wire_transport_test.dart b/test/io_wire_transport_test.dart deleted file mode 100644 index 4ba5e75..0000000 --- a/test/io_wire_transport_test.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:doh_resolver/doh_resolver_io.dart'; -import 'package:test/test.dart'; - -import 'support/dns_wire.dart'; -import 'support/test_pki.dart'; -import 'support/tunnel_proxy.dart'; - -void main() { - late HttpServer server; - late TestPki pki; - setUpAll(() => pki = TestPki.generate()); - setUp(() async => server = await HttpServer.bindSecure( - InternetAddress.loopbackIPv4, 0, pki.serverContext())); - tearDown(() => server.close(force: true)); - Uri endpoint() => Uri.parse('https://localhost:${server.port}/dns-query'); - - void serve( - {int status = 200, - String type = 'application/dns-message', - List Function(List query)? reply, - void Function(HttpRequest request, List body)? inspect}) { - server.listen((request) async { - final body = await request.fold>([], (a, b) => a..addAll(b)); - inspect?.call(request, body); - request.response - ..statusCode = status - ..headers.contentType = ContentType.parse(type) - ..add((reply ?? (q) => wireReply(id: q[0] << 8 | q[1]))(body)); - await request.response.close(); - }); - } - - for (final proxied in [false, true]) { - test('padded RFC 8484 POST over ${proxied ? 'SOCKS' : 'direct'} HTTPS', - () async { - List? sent; - serve(inspect: (request, body) { - expect(request.method, 'POST'); - expect( - request.headers.contentType?.mimeType, 'application/dns-message'); - expect(request.headers.value('accept'), 'application/dns-message'); - sent = body; - }); - final proxy = TunnelProxy(upstreamPort: server.port); - if (proxied) { - await proxy.start(); - addTearDown(proxy.close); - } - final transport = IoDohTransport( - securityContext: pki.clientContext(), - proxy: proxied - ? SocksProxy(InternetAddress.loopbackIPv4, proxy.port) - : null); - final query = wireQuery(id: 0xbeef); - final response = await transport - .startWire(endpoint(), query, maxResponseBytes: 65535) - .result; - expect(response.statusCode, 200); - expect(response.body.sublist(0, 2), [0xbe, 0xef]); - expect(sent!.length % 128, 0); - expect(sent!.sublist(2, query.length - 2), - query.sublist(2, query.length - 2), - reason: 'only the ID and OPT length may change'); - if (proxied) { - expect(proxy.targetType, 3, reason: 'hostname must reach the proxy'); - expect(proxy.target, 'localhost'); - } - }); - } - - test('padding can be disabled', () async { - List? sent; - serve(inspect: (_, body) => sent = body); - final query = wireQuery(); - await IoDohTransport( - securityContext: pki.clientContext(), padQueries: false) - .startWire(endpoint(), query, maxResponseBytes: 65535) - .result; - expect(sent!.sublist(2), query.sublist(2)); - }); - - test('rejects invalid queries and URIs through the result, unconnected', - () async { - var requests = 0; - serve(inspect: (_, __) => requests++); - // One slot: a rejected start that leaked it would hang the valid request. - final transport = IoDohTransport( - securityContext: pki.clientContext(), maxConcurrentRequests: 1); - addTearDown(transport.close); - for (final query in [ - [], - wireReply(), - [...wireQuery(), 0] - ]) { - await expectLater( - transport.startWire(endpoint(), query, maxResponseBytes: 512).result, - throwsA(isA())); - } - for (final uri in [ - endpoint().replace(scheme: 'http'), - endpoint().replace(userInfo: 'user'), - endpoint().replace(fragment: 'x'), - endpoint().replace(port: 0), - endpoint().replace(port: 70000), - // Uri.parse accepts this; reading the port throws FormatException. - Uri.parse('https://localhost:99999999999999999999/dns-query'), - ]) { - await expectLater( - transport.startWire(uri, wireQuery(), maxResponseBytes: 512).result, - throwsA(isA())); - await expectLater(transport.start(uri, maxResponseBytes: 512).result, - throwsA(isA())); - } - await expectLater( - transport - .startWire(endpoint(), wireQuery(), maxResponseBytes: 0) - .result, - throwsA(isA())); - expect(requests, 0); - final response = await transport - .startWire(endpoint(), wireQuery(), maxResponseBytes: 512) - .result; - expect(response.statusCode, 200); - expect(requests, 1); - }); - - for (final (name, code, status, type, reply) in [ - ( - 'mismatched ID', - isA(), - 200, - 'application/dns-message', - (List q) => wireReply(id: (q[0] << 8 | q[1]) ^ 1) - ), - ( - 'another question', - isA(), - 200, - 'application/dns-message', - (List q) => wireReply(name: 'b.example', id: q[0] << 8 | q[1]) - ), - ( - 'truncated reply', - isA(), - 200, - 'application/dns-message', - (List q) => wireReply(id: q[0] << 8 | q[1], flags: 0x8380) - ), - ( - 'JSON content type', - isA(), - 200, - 'application/json', - (List q) => wireReply(id: q[0] << 8 | q[1]) - ), - ( - 'server error', - isA(), - 500, - 'application/dns-message', - (List q) => wireReply(id: q[0] << 8 | q[1]) - ), - ]) { - test('rejects $name', () async { - serve(status: status, type: type, reply: reply); - await expectLater( - IoDohTransport(securityContext: pki.clientContext()) - .startWire(endpoint(), wireQuery(), maxResponseBytes: 65535) - .result, - throwsA(code)); - }); - } - - for (final proxied in [false, true]) { - test( - 'request deadline tears down a ${proxied ? 'proxied' : 'direct'} ' - 'stalled exchange', () async { - final arrived = Completer(); - server.listen((request) { - if (!arrived.isCompleted) arrived.complete(); - }); - final proxy = TunnelProxy(upstreamPort: server.port); - if (proxied) { - await proxy.start(); - addTearDown(proxy.close); - } - final transport = IoDohTransport( - securityContext: pki.clientContext(), - requestTimeout: const Duration(milliseconds: 300), - proxy: proxied - ? SocksProxy(InternetAddress.loopbackIPv4, proxy.port) - : null); - await expectLater( - transport - .startWire(endpoint(), wireQuery(), maxResponseBytes: 512) - .result, - throwsA(isA())); - expect(arrived.isCompleted, isTrue); - if (proxied) { - await proxy.disconnected.future.timeout(const Duration(seconds: 2)); - } - }); - } - - test('concurrency limit queues requests and frees slots on every outcome', - () async { - // Concurrent handshakes race and the wire ID is the transport's, so key - // held requests by question name. - final held = )>{}; - var arrivals = 0; - server.listen((request) async { - final body = await request.fold>([], (a, b) => a..addAll(b)); - held[wireFirstLabel(body)] = (request, body); - arrivals++; - }); - Future until(bool Function() condition) async { - for (var i = 0; i < 200 && !condition(); i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - expect(condition(), isTrue); - } - - final transport = IoDohTransport( - securityContext: pki.clientContext(), - maxConcurrentRequests: 2, - requestTimeout: const Duration(seconds: 5)); - final operations = [ - for (var id = 1; id <= 4; id++) - transport.startWire(endpoint(), wireQuery(id: id, name: 'q$id.example'), - maxResponseBytes: 512) - ]; - final outcomes = [ - for (final operation in operations) - operation.result.then((r) => r, onError: (Object e) => e) - ]; - await until(() => arrivals == 2); - operations[3].cancel(); - expect(await outcomes[3], isA()); - operations[0].cancel(); - expect(await outcomes[0], isA()); - await until(() => arrivals == 3); - expect(held.keys, unorderedEquals(['q1', 'q2', 'q3'])); - for (final label in ['q2', 'q3']) { - final (request, body) = held[label]!; - request.response - ..headers.contentType = ContentType('application', 'dns-message') - ..add(wireEcho(body)); - unawaited(request.response.close()); - } - expect(await outcomes[1], isA()); - expect(await outcomes[2], isA()); - await Future.delayed(const Duration(milliseconds: 50)); - expect(arrivals, 3); - }); - - test('the waiting queue rejects excess work and cancellation frees space', - () async { - final held = []; - server.listen(held.add); - Future until(bool Function() condition) async { - for (var i = 0; i < 200 && !condition(); i++) { - await Future.delayed(const Duration(milliseconds: 10)); - } - expect(condition(), isTrue); - } - - Future answer(HttpRequest request) async { - final body = await request.fold>([], (a, b) => a..addAll(b)); - request.response - ..headers.contentType = ContentType('application', 'dns-message') - ..add(wireReply(id: body[0] << 8 | body[1])); - await request.response.close(); - } - - final transport = IoDohTransport( - securityContext: pki.clientContext(), - maxConcurrentRequests: 1, - maxQueuedRequests: 2); - final active = transport.startWire(endpoint(), wireQuery(id: 1), - maxResponseBytes: 512); - final cancelled = transport.startWire(endpoint(), wireQuery(id: 2), - maxResponseBytes: 512); - final queued = transport.startWire(endpoint(), wireQuery(id: 3), - maxResponseBytes: 512); - await expectLater( - transport - .startWire(endpoint(), wireQuery(id: 4), maxResponseBytes: 512) - .result, - throwsA(isA())); - final cancelledResult = - expectLater(cancelled.result, throwsA(isA())); - cancelled.cancel(); - await cancelledResult; - final replacement = transport.startWire(endpoint(), wireQuery(id: 5), - maxResponseBytes: 512); - - await until(() => held.length == 1); - await answer(held[0]); - expect((await active.result).body.sublist(0, 2), u16(1)); - await until(() => held.length == 2); - await answer(held[1]); - expect((await queued.result).body.sublist(0, 2), u16(3)); - await until(() => held.length == 3); - await answer(held[2]); - expect((await replacement.result).body.sublist(0, 2), u16(5)); - expect(held, hasLength(3)); - }); -} diff --git a/test/live_test.dart b/test/live_test.dart deleted file mode 100644 index 2087446..0000000 --- a/test/live_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:io'; - -import 'package:doh_resolver/doh_resolver_io.dart'; -import 'package:test/test.dart'; - -import 'support/dns_wire.dart'; - -final skip = Platform.environment['DOH_LIVE_TESTS'] == '1' - ? null - : 'Set DOH_LIVE_TESTS=1 to query public resolvers'; - -List dnssecQuery(String name, int type) => [ - ...wireHeader(id: 0, flags: 0x0110, ar: 1), - ...wireQuestion(wireName(name), type: type), - ...wireOpt(options: [0, 8, 0, 4, 0, 1, 0, 0]), - ]; - -void main() { - final endpoints = [ - Uri.https('dns.google', '/dns-query'), - Uri.https('cloudflare-dns.com', '/dns-query'), - ]; - for (final endpoint in endpoints) { - for (final (name, type) in [ - ('getmonero.org', 16), - ('.', 48), - ('no-such-label-8484.getmonero.org', 16), - ]) { - test('${endpoint.host} wire $name/$type', () async { - final response = await IoDohTransport() - .startWire(endpoint, dnssecQuery(name, type), - maxResponseBytes: 65535) - .result; - expect(response.statusCode, 200); - }, skip: skip); - } - } - for (final endpoint in [ - Uri.https('dns.google', '/resolve'), - Uri.https('cloudflare-dns.com', '/dns-query'), - ]) { - test('${endpoint.host} JSON resolver-asserted TXT', () async { - final result = - await DohResolver(transport: IoDohTransport(), endpoint: endpoint) - .lookupTxt('getmonero.org'); - expect(result.records, isNotEmpty); - }, skip: skip); - } - test('Google raw and Cloudflare quoted TXT decode identically', () async { - Future> texts(Uri endpoint) async => { - for (final record in (await DohResolver( - transport: IoDohTransport(), endpoint: endpoint) - .lookupTxt('getmonero.org')) - .records) - record.text - }; - expect(await texts(Uri.https('dns.google', '/resolve')), - await texts(Uri.https('cloudflare-dns.com', '/dns-query'))); - }, skip: skip); -} diff --git a/test/support/LICENSE b/test/support/LICENSE deleted file mode 100755 index 4a63907..0000000 --- a/test/support/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ - -The MIT License (MIT) - -Copyright (c) 2024 sneurlax - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/test/support/dns_wire.dart b/test/support/dns_wire.dart deleted file mode 100644 index cc63fb8..0000000 --- a/test/support/dns_wire.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:convert'; - -List wireName(String name) => [ - for (final label in name.split('.').where((l) => l.isNotEmpty)) ...[ - label.length, - ...ascii.encode(label) - ], - 0 - ]; - -List wireHeader( - {int id = 0x1234, - int flags = 0x0100, - int qd = 1, - int an = 0, - int ns = 0, - int ar = 0}) => - [ - for (final v in [id, flags, qd, an, ns, ar]) ...u16(v) - ]; - -List u16(int value) => [value >> 8 & 0xff, value & 0xff]; - -List wireQuestion(List name, {int type = 16, int dnsClass = 1}) => - [...name, ...u16(type), ...u16(dnsClass)]; - -List wireRecord(List owner, int type, List rdata, - {int dnsClass = 1}) => - [ - ...owner, - ...u16(type), - ...u16(dnsClass), - 0, - 0, - 0, - 60, - ...u16(rdata.length), - ...rdata - ]; - -List wireOpt( - {List options = const [], - int version = 0, - List owner = const [0]}) => - [ - ...owner, - ...u16(41), - ...u16(1232), - 0, - version, - 0x80, - 0, - ...u16(options.length), - ...options - ]; - -List wireQuery( - {String name = 'alice.example', - int id = 0x1234, - bool edns = true, - int type = 16}) => - [ - ...wireHeader(id: id, ar: edns ? 1 : 0), - ...wireQuestion(wireName(name), type: type), - if (edns) ...wireOpt(), - ]; - -List wireReply( - {String name = 'alice.example', - int id = 0x1234, - int flags = 0x8180, - List> answers = const [], - List> additional = const []}) => - [ - ...wireHeader( - id: id, flags: flags, an: answers.length, ar: additional.length + 1), - ...wireQuestion(wireName(name)), - for (final answer in answers) ...answer, - for (final record in additional) ...record, - ...wireOpt(), - ]; - -List txtRdata(String text) => [text.length, ...ascii.encode(text)]; - -/// A NOERROR reply that echoes [query]'s ID, question and OPT record. -List wireEcho(List query) => [...query] - ..[2] = 0x81 - ..[3] = 0x80; - -String wireFirstLabel(List message) => - ascii.decode(message.sublist(13, 13 + message[12])); - -/// A query whose last additional record is a SIG(0) (type 24) or TSIG -/// (type 250); both sign the header, including the ID. -List wireSignedQuery({int id = 0x1234, int type = 24}) => [ - ...wireHeader(id: id, ar: 2), - ...wireQuestion(wireName('alice.example')), - ...wireOpt(), - ...wireRecord( - [0], - type, - type == 24 - ? [ - ...u16(0), 13, 0, 0, 0, 0, 0, // - 0, 0, 0, 2, 0, 0, 0, 1, ...u16(7), - ...wireName('key.example'), 1, 2, 3, 4 - ] - : [...wireName('hmac-sha256'), 1, 2, 3, 4], - dnsClass: 255), - ]; diff --git a/test/support/scripted_server.dart b/test/support/scripted_server.dart deleted file mode 100644 index a8ea79d..0000000 --- a/test/support/scripted_server.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -class ScriptedExchange { - final SecureSocket socket; - final int connection; - final int index; - final List body; - ScriptedExchange(this.socket, this.connection, this.index, this.body); - - int get id => body[0] << 8 | body[1]; - - void send(List bytes) { - try { - socket.add(bytes); - } catch (_) {} - } -} - -List httpResponse(List body, - {int status = 200, - String type = 'application/dns-message', - bool chunked = false}) { - final head = 'HTTP/1.1 $status X\r\ncontent-type: $type\r\n' - '${chunked ? 'transfer-encoding: chunked' : 'content-length: ${body.length}'}' - '\r\n\r\n'; - return [ - ...latin1.encode(head), - if (chunked && body.isNotEmpty) ...[ - ...latin1.encode('${body.length.toRadixString(16)}\r\n'), - ...body, - ...latin1.encode('\r\n'), - ], - if (chunked) ...latin1.encode('0\r\n\r\n') else ...body, - ]; -} - -class ScriptedServer { - final SecurityContext context; - final FutureOr Function(ScriptedExchange exchange) handler; - late SecureServerSocket _server; - final _sockets = []; - final exchanges = []; - int connections = 0; - int closed = 0; - ScriptedServer(this.context, this.handler); - - int get port => _server.port; - - Future start() async { - _server = - await SecureServerSocket.bind(InternetAddress.loopbackIPv4, 0, context); - _server.listen(_serve, onError: (Object _) {}); - } - - void _serve(SecureSocket socket) { - final connection = ++connections; - _sockets.add(socket); - socket.done.ignore(); - var buffer = []; - socket.listen((data) { - buffer.addAll(data); - while (true) { - final end = - latin1.decode(buffer, allowInvalid: true).indexOf('\r\n\r\n'); - if (end < 0) return; - final head = latin1.decode(buffer.sublist(0, end)).toLowerCase(); - final size = int.parse( - RegExp(r'content-length: *(\d+)').firstMatch(head)?.group(1) ?? - '0'); - if (buffer.length < end + 4 + size) return; - final exchange = ScriptedExchange(socket, connection, - exchanges.length + 1, buffer.sublist(end + 4, end + 4 + size)); - buffer = buffer.sublist(end + 4 + size); - exchanges.add(exchange); - runZonedGuarded(() => handler(exchange), (_, __) {}); - } - }, onError: (Object _) {}, onDone: () => closed++); - } - - Future close() async { - for (final socket in _sockets) { - socket.destroy(); - } - await _server.close(); - } -} diff --git a/test/support/test_pki.dart b/test/support/test_pki.dart deleted file mode 100644 index 99426e1..0000000 --- a/test/support/test_pki.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:pointycastle/export.dart'; - -class TestPki { - final String caPem; - final String leafPem; - final String keyPem; - TestPki._(this.caPem, this.leafPem, this.keyPem); - - factory TestPki.generate( - {List dnsNames = const ['localhost'], - List? ipAddresses, - Duration validity = const Duration(days: 30)}) { - final now = DateTime.now().toUtc(); - final notBefore = now.subtract(const Duration(hours: 1)); - final notAfter = now.add(validity); - final ca = _generateKey(); - final leaf = _generateKey(); - final caName = _name('doh_resolver test CA'); - final caCert = _certificate( - issuer: caName, - subject: caName, - subjectKey: ca, - signingKey: ca, - notBefore: notBefore, - notAfter: notAfter, - extensions: [ - _extension( - '2.5.29.19', - _seq([ - _der(0x01, [0xff]) - ]), - critical: true), - _extension('2.5.29.15', [0x03, 0x02, 0x01, 0x06], critical: true), - _extension('2.5.29.14', _der(0x04, _keyId(ca))), - ]); - final ips = ipAddresses ?? [InternetAddress.loopbackIPv4]; - final leafCert = _certificate( - issuer: caName, - subject: _name(dnsNames.first), - subjectKey: leaf, - signingKey: ca, - notBefore: notBefore, - notAfter: notAfter, - extensions: [ - _extension('2.5.29.19', _seq([]), critical: true), - _extension('2.5.29.15', [0x03, 0x02, 0x07, 0x80], critical: true), - _extension('2.5.29.37', _seq([_oid('1.3.6.1.5.5.7.3.1')])), - _extension( - '2.5.29.17', - _seq([ - for (final name in dnsNames) _der(0x82, ascii.encode(name)), - for (final ip in ips) _der(0x87, ip.rawAddress), - ])), - _extension('2.5.29.35', _seq([_der(0x80, _keyId(ca))])), - ]); - return TestPki._(_pem('CERTIFICATE', caCert), _pem('CERTIFICATE', leafCert), - _pem('PRIVATE KEY', _pkcs8(leaf))); - } - - SecurityContext serverContext() => SecurityContext(withTrustedRoots: false) - ..useCertificateChainBytes(utf8.encode(leafPem + caPem)) - ..usePrivateKeyBytes(utf8.encode(keyPem)); - - SecurityContext clientContext() => SecurityContext(withTrustedRoots: false) - ..setTrustedCertificatesBytes(utf8.encode(caPem)); -} - -final _domain = ECDomainParameters('prime256v1'); - -AsymmetricKeyPair _generateKey() { - final seed = Random.secure(); - final random = FortunaRandom() - ..seed(KeyParameter( - Uint8List.fromList(List.generate(32, (_) => seed.nextInt(256))))); - final generator = ECKeyGenerator() - ..init(ParametersWithRandom(ECKeyGeneratorParameters(_domain), random)); - return generator.generateKeyPair(); -} - -List _publicPoint(AsymmetricKeyPair key) => - key.publicKey.Q!.getEncoded(false); - -List _keyId(AsymmetricKeyPair key) => - SHA1Digest().process(Uint8List.fromList(_publicPoint(key))); - -List _certificate({ - required List issuer, - required List subject, - required AsymmetricKeyPair subjectKey, - required AsymmetricKeyPair signingKey, - required DateTime notBefore, - required DateTime notAfter, - required List> extensions, -}) { - final serial = Random.secure(); - final algorithm = _seq([_oid('1.2.840.10045.4.3.2')]); - final tbs = _seq([ - _der(0xa0, _integer(BigInt.two)), - _integer(BigInt.parse( - List.generate(16, (_) => serial.nextInt(256).toRadixString(16)) - .map((b) => b.padLeft(2, '0')) - .join(), - radix: 16)), - algorithm, - issuer, - _seq([_time(notBefore), _time(notAfter)]), - subject, - _spki(subjectKey), - _der(0xa3, _seq(extensions)), - ]); - final signer = ECDSASigner(SHA256Digest(), HMac(SHA256Digest(), 64)) - ..init(true, PrivateKeyParameter(signingKey.privateKey)); - final signature = - signer.generateSignature(Uint8List.fromList(tbs)) as ECSignature; - return _seq([ - tbs, - algorithm, - _der(0x03, [ - 0, - ..._seq([_integer(signature.r), _integer(signature.s)]) - ]), - ]); -} - -List _spki(AsymmetricKeyPair key) => _seq([ - _seq([_oid('1.2.840.10045.2.1'), _oid('1.2.840.10045.3.1.7')]), - _der(0x03, [0, ..._publicPoint(key)]), - ]); - -List _pkcs8(AsymmetricKeyPair key) { - final d = key.privateKey.d!.toRadixString(16).padLeft(64, '0'); - final scalar = [ - for (var i = 0; i < 64; i += 2) int.parse(d.substring(i, i + 2), radix: 16) - ]; - return _seq([ - _integer(BigInt.zero), - _seq([_oid('1.2.840.10045.2.1'), _oid('1.2.840.10045.3.1.7')]), - _der( - 0x04, - _seq([ - _integer(BigInt.one), - _der(0x04, scalar), - _der(0xa1, _der(0x03, [0, ..._publicPoint(key)])), - ])), - ]); -} - -List _name(String commonName) => _seq([ - _der(0x31, _seq([_oid('2.5.4.3'), _der(0x0c, utf8.encode(commonName))])) - ]); - -List _extension(String oid, List value, {bool critical = false}) => - _seq([ - _oid(oid), - if (critical) _der(0x01, [0xff]), - _der(0x04, value), - ]); - -List _time(DateTime time) { - String two(int value) => value.toString().padLeft(2, '0'); - return _der( - 0x17, - ascii.encode('${two(time.year % 100)}${two(time.month)}${two(time.day)}' - '${two(time.hour)}${two(time.minute)}${two(time.second)}Z')); -} - -List _integer(BigInt value) { - var hex = value.toRadixString(16); - if (hex.length.isOdd) hex = '0$hex'; - final bytes = [ - for (var i = 0; i < hex.length; i += 2) - int.parse(hex.substring(i, i + 2), radix: 16) - ]; - return _der(0x02, bytes.first >= 0x80 ? [0, ...bytes] : bytes); -} - -List _oid(String dotted) { - final parts = dotted.split('.').map(int.parse).toList(); - final body = [parts[0] * 40 + parts[1]]; - for (final part in parts.skip(2)) { - final chunk = [part & 0x7f]; - for (var v = part >> 7; v > 0; v >>= 7) { - chunk.insert(0, 0x80 | (v & 0x7f)); - } - body.addAll(chunk); - } - return _der(0x06, body); -} - -List _seq(List> items) => - _der(0x30, [for (final item in items) ...item]); - -List _der(int tag, List body) { - final length = body.length; - if (length < 0x80) return [tag, length, ...body]; - final bytes = []; - for (var v = length; v > 0; v >>= 8) { - bytes.insert(0, v & 0xff); - } - return [tag, 0x80 | bytes.length, ...bytes, ...body]; -} - -String _pem(String label, List der) { - final encoded = base64.encode(der); - final lines = [ - for (var i = 0; i < encoded.length; i += 64) - encoded.substring(i, min(i + 64, encoded.length)) - ]; - return '-----BEGIN $label-----\n${lines.join('\n')}\n-----END $label-----\n'; -} diff --git a/test/support/tunnel_proxy.dart b/test/support/tunnel_proxy.dart deleted file mode 100644 index fc321c6..0000000 --- a/test/support/tunnel_proxy.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -class TunnelProxy { - final int? upstreamPort; - final bool fragmented; - final int? stallAt; - final List initialData; - final int replyCode; - final int boundAddressType; - late ServerSocket server; - final sockets = []; - final connected = Completer(); - final disconnected = Completer(); - final greetingSeen = Completer(); - String? target; - int? targetPort; - int? targetType; - String? username; - String? password; - int connections = 0; - TunnelProxy( - {this.upstreamPort, - this.fragmented = false, - this.stallAt, - this.initialData = const [], - this.replyCode = 0, - this.boundAddressType = 1}); - - int get port => server.port; - Future start() async { - server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - server.listen((client) { - connections++; - sockets.add(client); - _serve(client); - }); - } - - Future _serve(Socket client) async { - final buffer = []; - Completer? changed; - Socket? upstream; - var closed = false; - client.listen( - (bytes) { - if (upstream != null) { - upstream.add(bytes); - } else { - buffer.addAll(bytes); - changed?.complete(); - changed = null; - } - }, - onError: (Object _) {}, - onDone: () { - closed = true; - changed?.complete(); - changed = null; - upstream?.destroy(); - if (!disconnected.isCompleted) disconnected.complete(); - }); - Future> read(int size) async { - while (buffer.length < size) { - if (closed) throw StateError('closed'); - changed = Completer(); - await changed!.future; - } - final value = buffer.sublist(0, size); - buffer.removeRange(0, size); - return value; - } - - Future send(List bytes) async { - if (fragmented) { - for (final byte in bytes) { - client.add([byte]); - await client.flush(); - await Future.delayed(const Duration(milliseconds: 1)); - } - } else { - client.add(bytes); - await client.flush(); - } - } - - try { - final head = await read(2); - final methods = await read(head[1]); - if (!greetingSeen.isCompleted) greetingSeen.complete(); - if (stallAt == 0) return; - final method = methods.contains(2) ? 2 : 0; - await send([5, method]); - if (method == 2) { - final auth = await read(2); - username = utf8.decode(await read(auth[1])); - password = utf8.decode(await read((await read(1)).single)); - await send([1, 0]); - } - final connect = await read(4); - targetType = connect[3]; - if (targetType == 3) { - target = ascii.decode(await read((await read(1)).single)); - } else { - target = InternetAddress.fromRawAddress( - Uint8List.fromList(await read(targetType == 1 ? 4 : 16))) - .address; - } - final portBytes = await read(2); - targetPort = portBytes[0] * 256 + portBytes[1]; - if (!connected.isCompleted) connected.complete(); - if (stallAt == 1) return; - if (upstreamPort != null) { - upstream = - await Socket.connect(InternetAddress.loopbackIPv4, upstreamPort!); - sockets.add(upstream); - upstream.listen(client.add, - onError: (Object _) {}, onDone: client.destroy); - } - final bound = switch (boundAddressType) { - 1 => [127, 0, 0, 1], - 4 => List.filled(16, 0), - 3 => [4, ...ascii.encode('test')], - _ => [], - }; - await send( - [5, replyCode, 0, boundAddressType, ...bound, 0, 0, ...initialData]); - if (upstream != null && buffer.isNotEmpty) { - upstream.add(buffer.toList()); - buffer.clear(); - } - } catch (_) { - client.destroy(); - } - } - - Future close() async { - for (final socket in sockets) { - socket.destroy(); - } - await server.close(); - } -} diff --git a/test/wire_test.dart b/test/wire_test.dart deleted file mode 100644 index fe3c222..0000000 --- a/test/wire_test.dart +++ /dev/null @@ -1,424 +0,0 @@ -import 'dart:math'; - -import 'package:doh_resolver/doh_resolver.dart'; -import 'package:test/test.dart'; - -import 'support/dns_wire.dart'; - -const pointer = [0xc0, 12]; - -void main() { - final query = wireQuery(); - - test('accepts a complete reply with compressed names and DNSSEC records', () { - final rrsig = [ - ...u16(16), 13, 2, 0, 0, 0, 60, // - 0, 0, 0, 0, 0, 0, 0, 0, ...u16(1), - ...wireName('example'), 1, 2, 3 - ]; - validateDnsWireResponse( - query, - wireReply(answers: [ - wireRecord(pointer, 5, [3, ...'pay'.codeUnits, 0xc0, 18]), - wireRecord(wireName('pay.example'), 16, txtRdata('oa1:xmr')), - wireRecord(wireName('pay.example'), 46, rrsig), - ])); - validateDnsWireResponse(query, wireReply(flags: 0x8183)); - }); - - test('parses every RFC 1035 legacy name-bearing RDATA format', () { - for (final type in [3, 4, 7, 8, 9]) { - validateDnsWireResponse( - query, wireReply(answers: [wireRecord(pointer, type, pointer)])); - } - validateDnsWireResponse( - query, - wireReply(answers: [ - wireRecord(pointer, 14, [...pointer, ...pointer]) - ])); - }); - - test('registers every pre-RFC 3597 embedded domain name', () { - final first = wireName('first.example'); - final second = wireName('second.example'); - final characterStrings = [1, 0x66, 1, 0x73, 1, 0x72]; - final cases = <(int, List, List)>[ - (17, [...first, ...second], [0, first.length]), - (18, [0, 1, ...first], [2]), - (21, [0, 1, ...first], [2]), - (24, [...List.filled(18, 0), ...first, 1], [18]), - (26, [0, 1, ...first, ...second], [2, 2 + first.length]), - (30, [...first, 0, 0, 0, 1], [0]), - (33, [...List.filled(6, 0), ...first], [6]), - ( - 35, - [...List.filled(4, 0), ...characterStrings, ...first], - [4 + characterStrings.length] - ), - (36, [0, 1, ...first], [2]), - (38, [128, ...first], [1]), - (39, first, [0]), - ]; - for (final (type, rdata, offsets) in cases) { - for (final offset in offsets) { - validateDnsWireResponse( - query, _ownerPointerIntoRdata(type, rdata, offset)); - } - } - }); - - test('registers uncompressed names embedded in newer RDATA types', () { - final first = wireName('first.example'); - final second = wireName('second.example'); - final cases = <(int, List, List)>[ - // SVCB and HTTPS: priority, TargetName, then SvcParams. - (64, [0, 1, ...first], [2]), - (65, [0, 1, ...first, 0, 1, 0, 2, 0x68, 0x32], [2]), - // HIP: HIT and public key, then rendezvous servers. - ( - 55, - [2, 1, 0, 1, 0xaa, 0xbb, 0xcc, ...first, ...second], - [7, 7 + first.length] - ), - (58, [...first, ...second], [0, first.length]), - (107, [0, 1, ...first], [2]), - (249, [...first, ...List.filled(16, 0)], [0]), - (250, [...first, ...List.filled(16, 0)], [0]), - // AMTRELAY with a domain-name relay (type 3). - (260, [0, 3, ...first], [2]), - (23, first, [0]), - // IPSECKEY with a domain-name gateway (type 3), then the public key. - (45, [10, 3, 2, ...first, 1, 2, 3], [3]), - // DSYNC: RRtype, scheme and port, then the target. - (66, [0, 59, 1, 0x14, 0xeb, ...first], [5]), - ]; - for (final (type, rdata, offsets) in cases) { - validateDnsWireResponse( - query, wireReply(answers: [wireRecord(pointer, type, rdata)])); - for (final offset in offsets) { - validateDnsWireResponse( - query, _ownerPointerIntoRdata(type, rdata, offset)); - } - } - }); - - test('newer RDATA names must not be compressed or overrun', () { - for (final (type, rdata) in <(int, List)>[ - (64, [0, 1, ...pointer]), - (65, [0, 1, ...pointer]), - (55, [1, 1, 0, 0, 0xaa, ...pointer]), - (55, [4, 1, 0, 0, 0xaa]), - (58, [...pointer, 0]), - (58, [0]), - (107, [0, 1, 0, 0]), - (250, [...pointer]), - (260, [0, 3, ...pointer]), - (260, [0, 1, 1, 2, 3]), - (260, [0, 0, 1]), - (23, pointer), - (45, [10, 3, 2, ...pointer]), - (45, [10, 1, 2, 1, 2, 3]), - (66, [0, 59, 1, 0x14, 0xeb, ...pointer]), - (66, [0, 59, 1, 0x14, 0xeb, 0, 1]), - ]) { - expect( - () => validateDnsWireResponse( - query, wireReply(answers: [wireRecord(pointer, type, rdata)])), - throwsA(isA()), - reason: 'type $type rdata $rdata'); - } - for (final (type, rdata) in <(int, List)>[ - (260, [0, 1, 1, 2, 3, 4]), - (260, [0, 2, ...List.filled(16, 0)]), - (260, [0, 0]), - (45, [10, 0, 2, 1, 2, 3]), - (45, [10, 1, 2, 1, 2, 3, 4, 9]), - (45, [10, 2, 0, ...List.filled(16, 0)]), - ]) { - validateDnsWireResponse( - query, wireReply(answers: [wireRecord(pointer, type, rdata)])); - } - }); - - test('accepts legacy RDATA compression required for compatibility', () { - for (final (type, rdata) in <(int, List)>[ - (17, [...pointer, ...pointer]), - (18, [0, 1, ...pointer]), - (21, [0, 1, ...pointer]), - (24, [...List.filled(18, 0), ...pointer, 1]), - (26, [0, 1, ...pointer, ...pointer]), - (30, [...pointer, 0, 0, 0, 1]), - (33, [...List.filled(6, 0), ...pointer]), - (35, [...List.filled(4, 0), 0, 0, 0, ...pointer]), - ]) { - validateDnsWireResponse( - query, wireReply(answers: [wireRecord(pointer, type, rdata)])); - } - }); - - test('question comparison ignores ASCII case only', () { - validateDnsWireResponse(query, wireReply(name: 'ALICE.example')); - expect( - () => validateDnsWireResponse(query, wireReply(name: 'alicf.example')), - throwsA(isA())); - }); - - test('rejects replies that do not answer the query', () { - for (final reply in [ - wireReply(id: 0x4321), - wireReply(flags: 0x8980), - wireReply(name: 'bob.example'), - [...wireHeader(flags: 0x8180, qd: 0)], - wireReply()..[query.length - 11 - 3] = 1, - wireReply()..[query.length - 11 - 1] = 3, - ]) { - expect(() => validateDnsWireResponse(query, reply), - throwsA(isA())); - } - }); - - test('rejects queries, truncated replies and unsupported EDNS versions', () { - for (final reply in [ - query, - wireReply(flags: 0x8380), - wireReply()..[query.length - 5] = 1, - ]) { - expect(() => validateDnsWireResponse(query, reply), - throwsA(isA())); - } - expect(() => validateDnsWireResponse(wireReply(), wireReply()), - throwsA(isA())); - }); - - group('hostile messages fail closed', () { - final cases = >{ - 'short header': wireReply().sublist(0, 11), - 'question past end': wireReply().sublist(0, 20), - 'trailing bytes': [...wireReply(), 0], - 'answer count beyond data': wireReply()..[7] = 255, - 'additional count beyond data': wireReply()..[11] = 2, - 'rdlength beyond data': wireReply( - answers: [wireRecord(pointer, 16, txtRdata('x')).sublist(0, 12)]), - 'pointer into header': wireReply(answers: [ - wireRecord([0xc0, 4], 16, txtRdata('x')) - ]), - 'pointer into question type': wireReply(answers: [ - wireRecord([0xc0, 27], 16, txtRdata('x')) - ]), - 'forward pointer': wireReply(answers: [ - wireRecord([0xc0, 200], 16, txtRdata('x')) - ]), - 'self pointer': _withOwnerAt((at) => [1, 0x61, 0xc0, at]), - 'pointer loop through earlier labels': _loop(), - 'extended label type': wireReply(answers: [ - wireRecord([0x41, 0], 16, txtRdata('x')) - ]), - 'label past end': [...wireHeader(flags: 0x8180), 40, 0x61], - 'name over 255 octets': wireReply(answers: [ - wireRecord([ - for (var i = 0; i < 5; i++) ...[63, ...List.filled(63, 0x61)], - 0 - ], 16, txtRdata('x')) - ]), - 'compressed name over 255 octets': _longCompressedName(), - 'OPT in answers': wireReply(answers: [wireOpt()]), - 'two OPT records': wireReply(additional: [wireOpt()]), - 'OPT with non-root owner': - wireReply(additional: [wireOpt(owner: wireName('example'))]), - 'OPT option overrun': [ - ...wireHeader(flags: 0x8180, ar: 1), - ...wireQuestion(wireName('alice.example')), - ...wireOpt(options: [0, 12, 0, 9, 0]) - ], - 'CNAME target overruns rdata': wireReply(answers: [ - wireRecord(pointer, 5, [3, 0x61, 0x62]) - ]), - 'CNAME junk after target': wireReply(answers: [ - wireRecord(pointer, 5, [...pointer, 0]) - ]), - 'compressed RRSIG signer': wireReply(answers: [ - wireRecord(pointer, 46, [...List.filled(18, 0), ...pointer, 1]) - ]), - 'SOA without counters': wireReply(answers: [ - wireRecord(pointer, 6, [...pointer, ...pointer, 0, 0, 0]) - ]), - 'legacy name pointer into header': wireReply(answers: [ - wireRecord(pointer, 7, [0xc0, 4]) - ]), - 'MINFO missing second name': - wireReply(answers: [wireRecord(pointer, 14, pointer)]), - 'RP name pointer into header': wireReply(answers: [ - wireRecord(pointer, 17, [0xc0, 4, ...pointer]) - ]), - 'NAPTR truncated character string': wireReply(answers: [ - wireRecord(pointer, 35, [...List.filled(4, 0), 2, 0]) - ]), - 'compressed KX exchanger': wireReply(answers: [ - wireRecord(pointer, 36, [0, 1, ...pointer]) - ]), - 'compressed A6 prefix name': wireReply(answers: [ - wireRecord(pointer, 38, [128, ...pointer]) - ]), - }; - cases.forEach((name, reply) { - test(name, () { - expect(() => validateDnsWireResponse(query, reply), - throwsA(isA())); - }); - }); - }); - - test('random corruption never escapes as a non-DNS error', () { - final random = Random(8484); - final valid = wireReply(answers: [ - wireRecord(pointer, 5, [3, ...'pay'.codeUnits, 0xc0, 18]), - wireRecord(wireName('pay.example'), 16, txtRdata('oa1:xmr')), - ]); - for (var i = 0; i < 5000; i++) { - final reply = [...valid]; - for (var j = random.nextInt(4); j >= 0; j--) { - reply[random.nextInt(reply.length)] = random.nextInt(256); - } - final cut = random.nextInt(8) == 0 ? random.nextInt(reply.length) : null; - try { - validateDnsWireResponse( - query, cut == null ? reply : reply.sublist(0, cut)); - } on DnsException { - // Expected for most mutations. - } - } - }); - - group('wire TTL adjustment', () { - int ttlAt(List message, int at) => - message[at] << 24 | - message[at + 1] << 16 | - message[at + 2] << 8 | - message[at + 3]; - - test('ages record TTLs and leaves OPT and the input untouched', () { - final txt = wireRecord(pointer, 16, txtRdata('x')); - final reply = wireReply(answers: [txt, txt], additional: [txt]); - final original = [...reply]; - final head = wireReply().length - wireOpt().length; - final ttls = [ - head + 6, - head + txt.length + 6, - head + 2 * txt.length + 6, - ]; - final aged = adjustDnsWireTtls(reply, age: const Duration(seconds: 25)); - expect([for (final at in ttls) ttlAt(aged, at)], [35, 35, 35]); - expect(aged.sublist(aged.length - wireOpt().length), wireOpt()); - expect(reply, original); - validateDnsWireResponse(query, aged); - expect( - ttlAt(adjustDnsWireTtls(reply, age: const Duration(hours: 1)), - ttls.first), - 0); - }); - - test('treats high-bit TTLs as zero', () { - final reply = - wireReply(answers: [wireRecord(pointer, 16, txtRdata('x'))]); - final at = wireReply().length - wireOpt().length + 6; - reply[at] = 0x80; - expect(ttlAt(adjustDnsWireTtls(reply, age: Duration.zero), at), 0); - }); - - test('rejects malformed messages and negative ages', () { - expect(() => adjustDnsWireTtls([0, 1, 2], age: Duration.zero), - throwsA(isA())); - final empty = wireHeader(flags: 0x8180, qd: 0); - expect(() => adjustDnsWireTtls(empty, age: const Duration(seconds: -1)), - throwsArgumentError, - reason: 'even with no TTL to adjust'); - }); - }); - - test('effectiveDnsTtl applies RFC 2181 and the HTTP age', () { - expect(effectiveDnsTtl(600), 600); - expect(effectiveDnsTtl(600, age: const Duration(seconds: 250)), 350); - expect(effectiveDnsTtl(60, age: const Duration(seconds: 61)), 0); - expect(effectiveDnsTtl(0x7fffffff), 0x7fffffff); - expect(effectiveDnsTtl(0x80000000), 0); - for (final ttl in [-1, 0x100000000]) { - expect(() => effectiveDnsTtl(ttl), throwsArgumentError); - } - expect(() => effectiveDnsTtl(1, age: const Duration(seconds: -1)), - throwsArgumentError); - }); - - group('query padding', () { - test('pads a final OPT record to a 128-octet block', () { - final padded = padDnsWireQuery(query); - expect(padded.length % 128, 0); - final option = padded.sublist(query.length); - expect(option.sublist(0, 2), [0, 12]); - expect(option.sublist(2, 4), u16(padded.length - query.length - 4)); - expect(option.skip(4), everyElement(0)); - expect(padded.sublist(query.length - 2, query.length), - u16(padded.length - query.length)); - validateDnsWireResponse(padded, wireReply()); - expect(padDnsWireQuery(padded), padded); - }); - - test('leaves queries without a final OPT record unchanged', () { - final plain = wireQuery(edns: false); - expect(padDnsWireQuery(plain), plain); - final optFirst = [ - ...wireHeader(ar: 2), - ...wireQuestion(wireName('alice.example')), - ...wireOpt(), - ...wireRecord([0], 16, txtRdata('x')) - ]; - expect(padDnsWireQuery(optFirst), optFirst); - }); - - test('rejects invalid queries', () { - for (final bad in [ - [], - [...query, 0], - wireReply(), - [...query]..[0] = 256, - ]) { - expect(() => padDnsWireQuery(bad), - throwsA(isA())); - } - }); - }); -} - -List _withOwnerAt(List Function(int at) owner) { - final prefix = wireReply().length - wireOpt().length; - return wireReply(answers: [wireRecord(owner(prefix), 16, txtRdata('x'))]); -} - -List _loop() { - final head = wireReply().length - wireOpt().length; - final label = head + 11 + 2; - return wireReply(answers: [ - wireRecord([0], 99, [0, 1, 0x61, 0xc0, label - 1]), - wireRecord([0xc0, label - 1], 16, txtRdata('x')), - ]); -} - -List _ownerPointerIntoRdata(int type, List rdata, int offset) { - final head = wireReply().length - wireOpt().length; - final target = head + 11 + offset; - return wireReply(answers: [ - wireRecord([0], type, rdata), - wireRecord([0xc0 | target >> 8, target & 0xff], 16, txtRdata('x')), - ]); -} - -List _longCompressedName() { - final head = wireReply().length - wireOpt().length; - final first = [ - for (var i = 0; i < 3; i++) ...[63, ...List.filled(63, 0x61)], - 0 - ]; - return wireReply(answers: [ - wireRecord(first, 16, txtRdata('x')), - wireRecord([63, ...List.filled(63, 0x62), 0xc0, head], 16, txtRdata('x')), - ]); -} From cac38bebd96be8872035816853a0e57c8440c090 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 14 Oct 2026 12:01:57 -0500 Subject: [PATCH 20/22] fix(dns): range-check Status and bound questions by maxAnswers --- packages/doh_resolver/CHANGELOG.md | 1 + packages/doh_resolver/lib/src/dns.dart | 29 ++++++++++------------- packages/doh_resolver/test/dns_test.dart | 30 ++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/doh_resolver/CHANGELOG.md b/packages/doh_resolver/CHANGELOG.md index 5525c4d..4b3af62 100644 --- a/packages/doh_resolver/CHANGELOG.md +++ b/packages/doh_resolver/CHANGELOG.md @@ -4,6 +4,7 @@ - Breaking: the transport, wire helpers and IO implementation moved to `doh_transport`, now a dependency. - Breaking: `DnsException` is `doh_transport`'s open base class, so exhaustive switches over it no longer compile. - Breaking: `AuthenticatedTxtResult` is renamed `ResolverAssertedTxtResult`; the old name is a deprecated typedef for one release. +- `DnsResponse.fromJson` rejects an out-of-range `Status` and bounds `Question` by `maxAnswers`. ## 0.0.1 - unreleased diff --git a/packages/doh_resolver/lib/src/dns.dart b/packages/doh_resolver/lib/src/dns.dart index 001fecd..46c7d81 100644 --- a/packages/doh_resolver/lib/src/dns.dart +++ b/packages/doh_resolver/lib/src/dns.dart @@ -85,17 +85,20 @@ class DnsResponse { this.age); /// Validates [json] from a DoH JSON API. Answer TTLs are reduced by the - /// response's HTTP cache [age] and normalized by [effectiveDnsTtl]. + /// response's HTTP cache [age] and normalized by [effectiveDnsTtl]. The + /// `Question` and `Answer` lists are each bounded by [maxAnswers]. factory DnsResponse.fromJson(Object? json, {required Uri resolver, int maxAnswers = 64, Duration age = Duration.zero}) { if (age < Duration.zero) throw ArgumentError.value(age, 'age'); + if (maxAnswers < 0) throw ArgumentError.value(maxAnswers, 'maxAnswers'); const malformed = DnsMalformedResponseException('Invalid DNS JSON response'); - if (json is! Map || json['Status'] is! int) { - throw malformed; - } + if (json is! Map) throw malformed; + final status = json['Status']; + // Extended RCODEs are 12 bits; providers have no reason to exceed 16. + if (status is! int || status < 0 || status > 0xffff) throw malformed; bool? flag(String name) { final value = json[name]; if (value != null && value is! bool) throw malformed; @@ -104,10 +107,9 @@ class DnsResponse { final rawQuestions = json['Question']; final rawAnswers = json['Answer'] ?? []; - if (rawQuestions is! List || - rawQuestions.length > 64 || - rawAnswers is! List) { - throw malformed; + if (rawQuestions is! List || rawAnswers is! List) throw malformed; + if (rawQuestions.length > maxAnswers) { + throw const DnsResponseTooLargeException('Too many DNS questions'); } if (rawAnswers.length > maxAnswers) { throw const DnsResponseTooLargeException('Too many DNS answers'); @@ -149,15 +151,8 @@ class DnsResponse { ttl: effectiveDnsTtl(a['TTL'] as int, age: age), data: a['data'] as String)); } - return DnsResponse._( - resolver, - json['Status'] as int, - flag('AD'), - flag('CD'), - flag('TC'), - List.unmodifiable(questions), - List.unmodifiable(answers), - age); + return DnsResponse._(resolver, status, flag('AD'), flag('CD'), flag('TC'), + List.unmodifiable(questions), List.unmodifiable(answers), age); } /// The TXT records answering [domain], following at most [maxOwnerVisits] diff --git a/packages/doh_resolver/test/dns_test.dart b/packages/doh_resolver/test/dns_test.dart index 075aeb3..757c837 100644 --- a/packages/doh_resolver/test/dns_test.dart +++ b/packages/doh_resolver/test/dns_test.dart @@ -20,8 +20,9 @@ Map rr(String name, int type, String data) => { 'TTL': 60, 'data': data, }; -DnsResponse decode(Object? json) => DnsResponse.fromJson(json, - resolver: Uri.parse('https://dns.example/resolve')); +final resolver = Uri.parse('https://dns.example/resolve'); +DnsResponse decode(Object? json) => + DnsResponse.fromJson(json, resolver: resolver); class FakeTransport implements DohTransport { final completer = Completer(); @@ -77,6 +78,31 @@ void main() { throwsA( isA().having((e) => e.rcode, 'rcode', 2))); }); + test('Status must be an in-range RCODE', () { + for (final status in [-1, 0x10000, 1.0, '0', null]) { + expect(() => decode(reply()..['Status'] = status), + throwsA(isA()), + reason: '$status'); + } + expect(decode(reply()..['Status'] = 0xffff).status, 0xffff); + }); + test('maxAnswers bounds the question and answer lists alike', () { + final question = {'name': 'alice.example.', 'type': 16}; + final answer = rr('alice.example.', 16, 'x'); + for (final (field, item) in [('Question', question), ('Answer', answer)]) { + final body = reply()..[field] = List.filled(3, item); + expect( + () => DnsResponse.fromJson(body, resolver: resolver, maxAnswers: 2), + throwsA(isA()), + reason: field); + expect(DnsResponse.fromJson(body, resolver: resolver, maxAnswers: 3), + isA(), + reason: field); + } + expect( + () => DnsResponse.fromJson(reply(), resolver: resolver, maxAnswers: -1), + throwsArgumentError); + }); test('HTTP failures carry the status', () async { final transport = FakeTransport(); final result = DohResolver(transport: transport).lookupTxt('alice.example'); From 78dd463db1757cc6fe19e05b3358cacbe8ae88eb Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 14 Oct 2026 12:02:21 -0500 Subject: [PATCH 21/22] feat(dns): expose the CNAME-chain minimum TTL on TXT results --- packages/doh_resolver/CHANGELOG.md | 1 + packages/doh_resolver/README.md | 1 + packages/doh_resolver/lib/src/dns.dart | 11 +++++++++++ packages/doh_resolver/test/dns_test.dart | 16 ++++++++++++++++ 4 files changed, 29 insertions(+) diff --git a/packages/doh_resolver/CHANGELOG.md b/packages/doh_resolver/CHANGELOG.md index 4b3af62..9646fcf 100644 --- a/packages/doh_resolver/CHANGELOG.md +++ b/packages/doh_resolver/CHANGELOG.md @@ -5,6 +5,7 @@ - Breaking: `DnsException` is `doh_transport`'s open base class, so exhaustive switches over it no longer compile. - Breaking: `AuthenticatedTxtResult` is renamed `ResolverAssertedTxtResult`; the old name is a deprecated typedef for one release. - `DnsResponse.fromJson` rejects an out-of-range `Status` and bounds `Question` by `maxAnswers`. +- `ResolverAssertedTxtResult.ttl` is the minimum TTL over the records and their CNAME chain. ## 0.0.1 - unreleased diff --git a/packages/doh_resolver/README.md b/packages/doh_resolver/README.md index 8833f1b..923087b 100644 --- a/packages/doh_resolver/README.md +++ b/packages/doh_resolver/README.md @@ -12,6 +12,7 @@ final transport = IoDohTransport(); try { final result = await DohResolver(transport: transport).lookupTxt('example.com'); + print('cache for ${result.ttl}s'); for (final record in result.records) { print(record.text); } diff --git a/packages/doh_resolver/lib/src/dns.dart b/packages/doh_resolver/lib/src/dns.dart index 46c7d81..fa5375f 100644 --- a/packages/doh_resolver/lib/src/dns.dart +++ b/packages/doh_resolver/lib/src/dns.dart @@ -56,6 +56,17 @@ final class ResolverAssertedTxtResult { List records, List aliases, this.response) : records = List.unmodifiable(records), aliases = List.unmodifiable(aliases); + + /// How long the answer may be cached, in seconds: the smallest TTL over + /// [records] and the [aliases] leading to them. Null for a NODATA answer, + /// whose negative lifetime comes from an SOA the JSON API does not return. + int? get ttl { + if (records.isEmpty) return null; + return [ + for (final record in records) record.ttl, + for (final alias in aliases) alias.ttl, + ].reduce((a, b) => a < b ? a : b); + } } /// Use [ResolverAssertedTxtResult]: the result asserts nothing itself. diff --git a/packages/doh_resolver/test/dns_test.dart b/packages/doh_resolver/test/dns_test.dart index 757c837..ac55d1e 100644 --- a/packages/doh_resolver/test/dns_test.dart +++ b/packages/doh_resolver/test/dns_test.dart @@ -78,6 +78,22 @@ void main() { throwsA( isA().having((e) => e.rcode, 'rcode', 2))); }); + test('ttl is the minimum over the CNAME chain and null for NODATA', () { + ResolverAssertedTxtResult txt(List> answers) => + decode(reply(answers: answers)) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw); + expect( + txt([ + rr('alice.example', 5, 'pay.example')..['TTL'] = 30, + rr('pay.example', 5, 'txt.example')..['TTL'] = 300, + rr('txt.example', 16, 'one')..['TTL'] = 90, + rr('txt.example', 16, 'two')..['TTL'] = 45, + ]).ttl, + 30); + expect(txt([rr('alice.example', 16, 'x')..['TTL'] = 7]).ttl, 7); + expect(txt([rr('alice.example', 5, 'pay.example')]).ttl, isNull); + expect(txt([]).ttl, isNull); + }); test('Status must be an in-range RCODE', () { for (final status in [-1, 0x10000, 1.0, '0', null]) { expect(() => decode(reply()..['Status'] = status), From 03b9554370305dda378b9901e01db627ed8cd3da Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 14 Oct 2026 13:14:32 -0500 Subject: [PATCH 22/22] docs: trim comments that restate the code --- packages/doh_resolver/lib/src/dns.dart | 13 +++---------- packages/doh_resolver/lib/src/resolver.dart | 20 ++++++-------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/packages/doh_resolver/lib/src/dns.dart b/packages/doh_resolver/lib/src/dns.dart index fa5375f..b11155c 100644 --- a/packages/doh_resolver/lib/src/dns.dart +++ b/packages/doh_resolver/lib/src/dns.dart @@ -43,8 +43,6 @@ class DnsQuestion { /// TXT records from an AD-flagged resolver response. An empty [records] list is /// a NODATA answer the resolver asserts is authenticated. -/// -/// Records only the resolver's AD assertion; it is not validation evidence. final class ResolverAssertedTxtResult { final String question; final String canonicalName; @@ -58,8 +56,7 @@ final class ResolverAssertedTxtResult { aliases = List.unmodifiable(aliases); /// How long the answer may be cached, in seconds: the smallest TTL over - /// [records] and the [aliases] leading to them. Null for a NODATA answer, - /// whose negative lifetime comes from an SOA the JSON API does not return. + /// [records] and the [aliases] leading to them. Null for a NODATA answer. int? get ttl { if (records.isEmpty) return null; return [ @@ -69,7 +66,6 @@ final class ResolverAssertedTxtResult { } } -/// Use [ResolverAssertedTxtResult]: the result asserts nothing itself. @Deprecated('Use ResolverAssertedTxtResult') typedef AuthenticatedTxtResult = ResolverAssertedTxtResult; @@ -108,7 +104,6 @@ class DnsResponse { DnsMalformedResponseException('Invalid DNS JSON response'); if (json is! Map) throw malformed; final status = json['Status']; - // Extended RCODEs are 12 bits; providers have no reason to exceed 16. if (status is! int || status < 0 || status > 0xffff) throw malformed; bool? flag(String name) { final value = json[name]; @@ -224,8 +219,7 @@ class DnsResponse { } } -/// How a JSON DoH resolver writes TXT `data`. Providers differ and a value -/// alone cannot reveal which form it is in, so the format is set per endpoint. +/// How a JSON DoH resolver writes TXT `data`; set per endpoint. enum DnsTxtFormat { /// Quoted RFC 1035 character-strings separated by spaces, with `\DDD` /// decimal and `\X` literal escapes, as Cloudflare returns them. @@ -236,8 +230,7 @@ enum DnsTxtFormat { raw, } -/// Decodes JSON TXT `data` in [format] to the record's octets. DNS decimal -/// escapes encode octets, so UTF-8 is decoded only after chunks are joined. +/// Decodes JSON TXT `data` in [format] to the record's octets. List decodeDnsTxtBytes(String value, DnsTxtFormat format, {int maxPresentationLength = 8192}) { if (value.length > maxPresentationLength) { diff --git a/packages/doh_resolver/lib/src/resolver.dart b/packages/doh_resolver/lib/src/resolver.dart index 84aab65..7ad4f6f 100644 --- a/packages/doh_resolver/lib/src/resolver.dart +++ b/packages/doh_resolver/lib/src/resolver.dart @@ -21,15 +21,13 @@ class DohResolver { /// How [endpoint] writes TXT data. Known Google and Cloudflare hosts get /// their format by default; any other endpoint must pass it explicitly. final DnsTxtFormat txtFormat; - // Decoded once so a malformed endpoint fails construction, not each lookup. late final Map> _endpointParameters; /// Creates a resolver for [endpoint], Google's JSON API by default. /// - /// Throws [ArgumentError] for a non-HTTPS, credential-bearing, - /// fragment-bearing, unusably-ported or undecodable endpoint, invalid limits, or an unknown - /// provider without [txtFormat]. Endpoint query parameters are kept, - /// including repeated ones, except those the resolver sets itself. + /// Throws [ArgumentError] for an unusable [endpoint], invalid limits, or an + /// unknown provider without [txtFormat]. Endpoint query parameters are kept + /// except those the resolver sets itself. DohResolver( {required this.transport, Uri? endpoint, @@ -62,11 +60,9 @@ class DohResolver { /// Starts a cancellable TXT lookup for [domain]. /// - /// The request asks for DNSSEC data with checking enabled, sends - /// `edns_client_subnet=0.0.0.0/0` (honoured by providers that implement it, - /// such as Google), and pads the URL to a multiple of 128 characters so the - /// name length is hidden. Never throws: every failure is a [DnsException] in - /// the result. + /// Sends `do=true`, `cd=false` and `edns_client_subnet=0.0.0.0/0`, and pads + /// the URL to a multiple of 128 characters. Never throws: every failure is a + /// [DnsException] in the result. DnsOperation startTxtLookup(String domain) { try { domain = normalizeDnsName(domain); @@ -83,7 +79,6 @@ class DohResolver { 'edns_client_subnet': '0.0.0.0/0', 'random_padding': 'X', }; - // The JSON API ignores this value; it hides the name length in 128-octet blocks. final length = endpoint.replace(queryParameters: parameters).toString().length; parameters['random_padding'] = 'X' * (1 + (128 - length % 128) % 128); @@ -99,8 +94,6 @@ class DohResolver { } final result = Completer(); late Timer timer; - // Only an unfinished transport operation is cancelled; once its result - // has completed, the transport owns no further work for this lookup. void abort(DnsException error) { if (result.isCompleted) return; result.completeError(error); @@ -133,7 +126,6 @@ class DohResolver { const DnsMalformedResponseException('Invalid DNS JSON encoding'), stack); } catch (_, stack) { - // The deadline is cancelled below, so every outcome must complete here. result.completeError( const DnsMalformedResponseException('Unreadable DNS response'), stack);