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..80992a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,13 +4,28 @@ 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 + # 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 6d5f686..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,7 +0,0 @@ -## 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. diff --git a/README.md b/README.md deleted file mode 100644 index 7b17d2d..0000000 --- a/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# doh_resolver - -Bounded JSON and wire-format DNS-over-HTTPS with direct and SOCKS transports. - -## 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 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`. diff --git a/lib/doh_resolver.dart b/lib/doh_resolver.dart deleted file mode 100644 index 1395940..0000000 --- a/lib/doh_resolver.dart +++ /dev/null @@ -1,5 +0,0 @@ -library; - -export 'src/dns.dart'; -export 'src/resolver.dart'; -export 'src/wire.dart' show validateDnsWireResponse, padDnsWireQuery; diff --git a/lib/doh_resolver_io.dart b/lib/doh_resolver_io.dart deleted file mode 100644 index 0355a69..0000000 --- a/lib/doh_resolver_io.dart +++ /dev/null @@ -1,6 +0,0 @@ -library; - -export 'package:socks_socket/socks_connection.dart' show SocksCredentials; - -export 'doh_resolver.dart'; -export 'src/io_transport.dart'; diff --git a/lib/src/dns.dart b/lib/src/dns.dart deleted file mode 100644 index 23eb3d0..0000000 --- a/lib/src/dns.dart +++ /dev/null @@ -1,366 +0,0 @@ -import 'dart:convert'; - -enum DnsError { - invalidName, - malformedResponse, - responseTooLarge, - queryFailed, - authenticationUnavailable, - questionMismatch, - aliasLoop, - aliasAmbiguous, - aliasTooLong, - noRecords, - transport, - cancelled, - timedOut, -} - -class DnsException implements Exception { - final DnsError code; - final String message; - final int? responseCode; - const DnsException(this.code, this.message, {this.responseCode}); - @override - String toString() => 'DnsException(${code.name}): $message'; -} - -final _label = RegExp(r'^[a-z0-9_-]+$'); - -String normalizeDnsName(String name) { - if (name.codeUnits.any((c) => c > 127)) { - throw const DnsException(DnsError.invalidName, "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 DnsException(DnsError.invalidName, 'Invalid ASCII DNS name'); - } - return value; -} - -class DnsQuestion { - final String name; - final int type; - const DnsQuestion(this.name, this.type); -} - -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}); -} - -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); - - String get text { - try { - return utf8.decode(bytes); - } on FormatException { - throw const DnsException(DnsError.malformedResponse, 'TXT is not UTF-8'); - } - } -} - -enum DnsAuthentication { resolverAsserted, locallyValidated } - -final class DnsValidator { - final String name; - DnsValidator._(this.name); - - @override - String toString() => 'DnsValidator($name)'; -} - -final class DnsValidationIssuer { - final DnsValidator validator; - DnsValidationIssuer(String name) : validator = DnsValidator._(name) { - if (name.isEmpty) throw ArgumentError.value(name, 'name'); - } - - // Call only after signatures are verified; this checks owner binding. - 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 DnsException( - DnsError.malformedResponse, '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 DnsException( - DnsError.malformedResponse, 'Invalid validated CNAME chain'); - } - chain.add( - DnsRecord(name: owner, type: 5, ttl: alias.ttl, data: alias.data)); - owner = normalizeDnsName(alias.data); - if (!seen.add(owner)) { - throw const DnsException(DnsError.aliasLoop, 'CNAME loop'); - } - } - if (owner != canonicalName) { - throw const DnsException( - DnsError.questionMismatch, 'CNAME chain does not reach TXT owner'); - } - return AuthenticatedTxtResult._( - question, - canonicalName, - resolver, - [ - for (final r in records) - DnsTxtRecord(name: canonicalName, ttl: r.ttl, bytes: r.bytes) - ], - chain, - null, - DnsAuthentication.locallyValidated, - validator); - } -} - -final class AuthenticatedTxtResult { - 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]) - : records = List.unmodifiable(records), - aliases = List.unmodifiable(aliases); -} - -class DnsResponse { - final Uri resolver; - final int status; - final bool? authenticatedData; - final bool? checkingDisabled; - final bool? truncated; - final List questions; - final List answers; - DnsResponse._(this.resolver, this.status, this.authenticatedData, - this.checkingDisabled, this.truncated, this.questions, this.answers); - - factory DnsResponse.fromJson(Object? json, - {required Uri resolver, int maxAnswers = 64}) { - const malformed = - DnsException(DnsError.malformedResponse, 'Invalid DNS JSON response'); - if (json is! Map || json['Status'] is! int) { - throw malformed; - } - bool? flag(String name) { - final value = json[name]; - if (value != null && value is! bool) throw malformed; - return value as bool?; - } - - final rawQuestions = json['Question']; - final rawAnswers = json['Answer'] ?? []; - if (rawQuestions is! List || - rawQuestions.length > 64 || - rawAnswers is! List) { - throw malformed; - } - if (rawAnswers.length > maxAnswers) { - throw const DnsException( - DnsError.responseTooLarge, 'Too many DNS answers'); - } - String name(Object? input) { - if (input is! String) throw malformed; - try { - return normalizeDnsName(input); - } on DnsException { - throw malformed; - } - } - - final questions = []; - for (final q in rawQuestions) { - if (q is! Map || - q['type'] is! int || - (q['type'] as int) < 0 || - (q['type'] as int) > 0xffff) { - throw malformed; - } - questions.add(DnsQuestion(name(q['name']), q['type'] as int)); - } - final answers = []; - for (final a in rawAnswers) { - if (a is! Map || - a['type'] is! int || - a['TTL'] is! int || - (a['TTL'] as int) < 0 || - (a['TTL'] as int) > 0xffffffff || - (a['type'] as int) < 0 || - (a['type'] as int) > 0xffff || - a['data'] is! String) { - throw malformed; - } - answers.add(DnsRecord( - name: name(a['name']), - type: a['type'] as int, - ttl: a['TTL'] as int, - data: a['data'] as String)); - } - return DnsResponse._( - resolver, - json['Status'] as int, - flag('AD'), - flag('CD'), - flag('TC'), - List.unmodifiable(questions), - List.unmodifiable(answers)); - } - - AuthenticatedTxtResult authenticatedTxt(String domain, - {int maxOwnerVisits = 8}) { - domain = normalizeDnsName(domain); - if (status != 0) { - throw DnsException(DnsError.queryFailed, 'DNS query failed', - responseCode: status); - } - if (authenticatedData != true || - checkingDisabled != false || - truncated != false) { - throw const DnsException(DnsError.authenticationUnavailable, - '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'); - } - 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'); - } - final records = answers.where((record) => record.name == owner); - final cnames = records.where((record) => record.type == 5).toList(); - final txt = records.where((record) => record.type == 16).toList(); - if (cnames.isEmpty) { - return AuthenticatedTxtResult._( - domain, - owner, - resolver, - txt - .map((r) => DnsTxtRecord( - name: owner, ttl: r.ttl, bytes: decodeDnsTxtBytes(r.data))) - .toList(), - aliases, - this); - } - if (cnames.length != 1 || txt.isNotEmpty) { - throw const DnsException( - DnsError.aliasAmbiguous, '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 DnsException( - DnsError.aliasTooLong, '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'); - } - if (!value.startsWith('"')) return utf8.encode(value); - const malformed = - DnsException(DnsError.malformedResponse, 'Malformed TXT presentation'); - final bytes = []; - var i = 0; - while (i < value.length) { - while (i < value.length && value[i] == ' ') { - i++; - } - if (i == value.length) break; - if (value[i++] != '"') throw malformed; - var closed = false; - while (i < value.length) { - if (value[i] == '"') { - i++; - closed = true; - break; - } - if (value[i] == '\\') { - i++; - if (i == value.length) throw malformed; - bool digit(int at) => - at < value.length && - value.codeUnitAt(at) >= 0x30 && - value.codeUnitAt(at) <= 0x39; - if (digit(i)) { - if (!digit(i + 1) || !digit(i + 2)) throw malformed; - final octet = int.parse(value.substring(i, i + 3)); - if (octet > 255) throw malformed; - bytes.add(octet); - i += 3; - } else { - final pair = value.codeUnitAt(i) & 0xfc00 == 0xd800 && - i + 1 < value.length && - value.codeUnitAt(i + 1) & 0xfc00 == 0xdc00; - final end = i + (pair ? 2 : 1); - bytes.addAll(utf8.encode(value.substring(i, end))); - i = end; - } - } else { - final start = i; - while (i < value.length && value[i] != '"' && value[i] != '\\') { - i++; - } - bytes.addAll(utf8.encode(value.substring(start, i))); - } - } - if (!closed) throw malformed; - } - return bytes; -} diff --git a/lib/src/io_transport.dart b/lib/src/io_transport.dart deleted file mode 100644 index a6684ba..0000000 --- a/lib/src/io_transport.dart +++ /dev/null @@ -1,341 +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'; - -class SocksProxy { - final InternetAddress host; - final int port; - final SocksCredentials? credentials; - const SocksProxy(this.host, this.port, {this.credentials}); -} - -class IoDohTransport implements DohTransport, DohWireTransport { - final SocksProxy? proxy; - final InternetAddress? proxyDestination; - final SecurityContext? securityContext; - final Duration connectionTimeout; - final Duration requestTimeout; - final Duration idleTimeout; - final int maxConcurrentRequests; - final int maxQueuedRequests; - final bool padQueries; - final bool reuseConnections; - int _active = 0; - bool _closed = false; - final _waiting = Queue<_IoRequest>(); - final _running = <_IoRequest>{}; - final _idle = <_Lane>[]; - 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, - this.reuseConnections = true}) { - 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}) { - return _start(uri, min(maxResponseBytes, 65535), - padQueries ? padDnsWireQuery(query) : checkedDnsWireQuery(query)); - } - - 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]) { - if (uri.scheme != 'https' || - uri.host.isEmpty || - uri.userInfo.isNotEmpty || - uri.hasFragment || - maxResponseBytes < 1) { - throw ArgumentError('Expected a bounded HTTPS request'); - } - if (_closed) { - return DnsOperation( - Future.error( - const DnsException(DnsError.cancelled, 'DNS transport closed')), - () {}); - } - if (_active >= maxConcurrentRequests && - _waiting.length >= maxQueuedRequests) { - return DnsOperation( - Future.error(const DnsException( - DnsError.transport, 'DNS transport request queue is full')), - () {}); - } - final request = _IoRequest(this, uri, maxResponseBytes, query); - 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 id = 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)) { - _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; - _running.add(request); - lane.zone.run(request.run); - } - - _Lane _newLane(_Origin origin) { - final lane = _Lane(origin); - lane.zone = Zone.current.fork(specification: - ZoneSpecification(handleUncaughtError: (_, __, ___, error, stack) { - if (lane.owner case final owner?) { - owner._finish( - const DnsException( - DnsError.transport, '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 DnsException( - DnsError.cancelled, '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.markWireIdUsed(request.query![0] << 8 | request.query![1]); - lane.owner = null; - _idle.add(lane); - } else { - _retire(lane); - } - _active--; - while (_waiting.isNotEmpty && _active < maxConcurrentRequests) { - _active++; - _run(_waiting.removeFirst()); - } - } -} - -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); - _IoRequest? owner; - bool retired = false; - - _Lane(this.origin); - - bool hasUsedWireId(int id) => _usedWireIds[id >> 3] & (1 << (id & 7)) != 0; - - void markWireIdUsed(int id) => _usedWireIds[id >> 3] |= 1 << (id & 7); -} - -class _IoRequest { - final IoDohTransport transport; - final Uri uri; - final int limit; - final List? query; - final result = Completer(); - _Lane? lane; - 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'))); - } - - 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 DnsException(DnsError.cancelled, '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 (query != null) { - request.headers.contentType = ContentType('application', 'dns-message'); - request.contentLength = query!.length; - request.add(query!); - } - final response = await request.close(); - if (response.statusCode != 200) { - throw DnsException(DnsError.transport, 'Resolver HTTP request failed', - responseCode: 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'); - } - if (response.headers.value(HttpHeaders.contentEncodingHeader) - case final encoding? when encoding.toLowerCase() != 'identity') { - throw const DnsException( - DnsError.malformedResponse, 'Encoded DNS response'); - } - if (response.contentLength > limit) { - throw const DnsException( - DnsError.responseTooLarge, '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'); - } - bytes.add(chunk); - if (_done) return; - } - final body = bytes.takeBytes(); - if (query != null) validateDnsWireResponse(query!, body); - _finish(DohHttpResponse(response.statusCode, body)); - } catch (error, stack) { - _finish( - error is DnsException - ? error - : const DnsException( - DnsError.transport, 'Resolver connection failed'), - stack); - } - } -} diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart deleted file mode 100644 index bcdd4b3..0000000 --- a/lib/src/resolver.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dns.dart'; - -class DnsOperation { - final Future result; - final void Function() cancel; - const DnsOperation(this.result, this.cancel); -} - -class DohHttpResponse { - final int statusCode; - final List body; - DohHttpResponse(this.statusCode, List body) - : body = List.unmodifiable(body); -} - -abstract interface class DohTransport { - DnsOperation start(Uri uri, {required int maxResponseBytes}); -} - -abstract interface class DohWireTransport { - DnsOperation startWire(Uri uri, List query, - {required int maxResponseBytes}); -} - -class DohResolver { - final DohTransport transport; - final Uri endpoint; - final Duration timeout; - final int maxResponseBytes; - DohResolver( - {required this.transport, - Uri? endpoint, - this.timeout = const Duration(seconds: 12), - this.maxResponseBytes = 65536}) - : endpoint = endpoint ?? Uri.https('dns.google', '/resolve') { - if (this.endpoint.scheme != 'https' || - this.endpoint.host.isEmpty || - this.endpoint.userInfo.isNotEmpty || - this.endpoint.hasFragment) { - throw ArgumentError( - 'A credential-free HTTPS resolver endpoint is required'); - } - if (timeout <= Duration.zero || maxResponseBytes < 1) { - throw ArgumentError('Invalid query limits'); - } - } - - Future lookupTxt(String domain) => - startTxtLookup(domain).result; - - DnsOperation startTxtLookup(String domain) { - domain = normalizeDnsName(domain); - final parameters = { - ...endpoint.queryParameters, - 'name': '$domain.', - 'type': 'TXT', - 'do': 'true', - 'cd': 'false', - '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); - final operation = transport.start( - endpoint.replace(queryParameters: parameters), - maxResponseBytes: maxResponseBytes); - final result = Completer(); - late Timer timer; - void release() { - 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)); - operation.result.then((response) { - if (result.isCompleted) return; - try { - if (response.statusCode != 200) { - throw DnsException(DnsError.transport, 'DNS HTTPS request failed', - responseCode: response.statusCode); - } - if (response.body.length > maxResponseBytes) { - throw const DnsException( - DnsError.responseTooLarge, 'DNS body exceeds limit'); - } - final body = jsonDecode(utf8.decode(response.body)); - result.complete(DnsResponse.fromJson(body, resolver: endpoint) - .authenticatedTxt(domain)); - } on DnsException catch (error, stack) { - result.completeError(error, stack); - } on FormatException catch (_, stack) { - result.completeError( - const DnsException( - DnsError.malformedResponse, 'Invalid DNS JSON encoding'), - stack); - } finally { - release(); - } - }, onError: (Object error, StackTrace stack) { - release(); - if (!result.isCompleted) { - result.completeError( - error is DnsException - ? error - : const DnsException( - DnsError.transport, 'DNS transport failed'), - stack); - } - }); - return DnsOperation(result.future, () => abort(DnsError.cancelled)); - } -} diff --git a/lib/src/wire.dart b/lib/src/wire.dart deleted file mode 100644 index fb065e9..0000000 --- a/lib/src/wire.dart +++ /dev/null @@ -1,311 +0,0 @@ -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'); - -/// 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'); - } - final reply = _WireMessage.parse(response); - if (!reply.isResponse) throw _malformed; - if (reply.flags & 0x0200 != 0) { - throw const DnsException( - DnsError.malformedResponse, '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; - } -} - -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 ArgumentError('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); -} - -List checkedDnsWireQuery(List query) { - final message = _parseOrNull(query); - if (message == null || message.isResponse) { - throw ArgumentError('Invalid DNS wire query'); - } - return List.unmodifiable(query); -} - -_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>[]; - final _nameStarts = {}; - int? optRdlengthOffset; - int? optVersion; - bool optIsLast = false; - bool optHasPadding = false; - 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) { - 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); - } - if (_offset > end || - ({ - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 12, - 14, - 15, - 17, - 18, - 21, - 26, - 33, - 35, - 36, - 38, - 39 - }.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); - } - - // Pointers must move strictly backwards past every earlier jump, so loops are impossible. - List _name({int? limit, bool compressed = true}) { - final name = []; - var at = _offset; - var bound = limit ?? data.length; - var lowest = _offset; - int? resume; - while (true) { - if (at >= bound) throw _malformed; - _nameStarts.add(at); - final length = data[at]; - if (length == 0) { - name.add(0); - if (name.length > 255) throw _malformed; - _offset = resume ?? at + 1; - return name; - } - 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; - 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; - default: - throw _malformed; - } - } - } -} 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..9646fcf --- /dev/null +++ b/packages/doh_resolver/CHANGELOG.md @@ -0,0 +1,12 @@ +## 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. +- `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 + +- 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..923087b --- /dev/null +++ b/packages/doh_resolver/README.md @@ -0,0 +1,69 @@ +# 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'); + print('cache for ${result.ttl}s'); + 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 82% rename from example/doh_resolver_example.dart rename to packages/doh_resolver/example/doh_resolver_example.dart index 237129f..1cc270c 100644 --- a/example/doh_resolver_example.dart +++ b/packages/doh_resolver/example/doh_resolver_example.dart @@ -10,12 +10,12 @@ 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}'); } } on DnsException catch (error) { - print('Lookup rejected: ${error.code.name}'); + print('Lookup rejected: $error'); } } finally { await transport.close(); 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/packages/doh_resolver/lib/src/dns.dart b/packages/doh_resolver/lib/src/dns.dart new file mode 100644 index 0000000..b11155c --- /dev/null +++ b/packages/doh_resolver/lib/src/dns.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; + +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 { + final int rcode; + 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); +} + +/// The answer's CNAME chain could not be followed to a single owner. +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); +} + +/// A question from a JSON DNS response. +class DnsQuestion { + final String name; + final int type; + const DnsQuestion(this.name, this.type); +} + +/// TXT records from an AD-flagged resolver response. An empty [records] list is +/// a NODATA answer the resolver asserts is authenticated. +final class ResolverAssertedTxtResult { + final String question; + final String canonicalName; + final Uri resolver; + final List records; + final List aliases; + final DnsResponse? response; + ResolverAssertedTxtResult._(this.question, this.canonicalName, this.resolver, + 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. + 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); + } +} + +@Deprecated('Use ResolverAssertedTxtResult') +typedef AuthenticatedTxtResult = ResolverAssertedTxtResult; + +/// A bounded, validated JSON DNS response from [resolver]. +class DnsResponse { + final Uri resolver; + final int status; + final bool? authenticatedData; + final bool? checkingDisabled; + final bool? truncated; + final List questions; + final List answers; + + /// The HTTP cache age already subtracted from the answer TTLs. + final Duration age; + DnsResponse._( + this.resolver, + this.status, + this.authenticatedData, + this.checkingDisabled, + this.truncated, + this.questions, + 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]. 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) throw malformed; + final status = json['Status']; + 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; + return value as bool?; + } + + final rawQuestions = json['Question']; + final rawAnswers = json['Answer'] ?? []; + 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'); + } + String name(Object? input) { + if (input is! String) throw malformed; + try { + return normalizeDnsName(input); + } on DnsException { + throw malformed; + } + } + + final questions = []; + for (final q in rawQuestions) { + if (q is! Map || + q['type'] is! int || + (q['type'] as int) < 0 || + (q['type'] as int) > 0xffff) { + throw malformed; + } + questions.add(DnsQuestion(name(q['name']), q['type'] as int)); + } + final answers = []; + for (final a in rawAnswers) { + if (a is! Map || + a['type'] is! int || + a['TTL'] is! int || + (a['TTL'] as int) < 0 || + (a['TTL'] as int) > 0xffffffff || + (a['type'] as int) < 0 || + (a['type'] as int) > 0xffff || + a['data'] is! String) { + throw malformed; + } + answers.add(DnsRecord( + name: name(a['name']), + type: a['type'] as int, + ttl: effectiveDnsTtl(a['TTL'] as int, age: age), + data: a['data'] as String)); + } + 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] + /// owners through CNAMEs. Requires a successful, AD-flagged, untruncated + /// response with checking enabled and a single matching TXT question. + ResolverAssertedTxtResult authenticatedTxt(String domain, + {required DnsTxtFormat txtFormat, int maxOwnerVisits = 8}) { + domain = normalizeDnsName(domain); + if (status != 0) { + throw DnsQueryFailedException('DNS query failed', rcode: status); + } + if (authenticatedData != true || + checkingDisabled != false || + truncated != false) { + throw const DnsAuthenticationUnavailableException( + 'Authenticated complete DNS response required'); + } + if (questions.length != 1 || + questions.single.type != 16 || + questions.single.name != domain) { + 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 DnsAliasLoopException('CNAME loop'); + } + final records = answers.where((record) => record.name == owner); + final cnames = records.where((record) => record.type == 5).toList(); + final txt = records.where((record) => record.type == 16).toList(); + if (cnames.isEmpty) { + return ResolverAssertedTxtResult._( + domain, + owner, + resolver, + txt + .map((r) => DnsTxtRecord( + name: owner, + ttl: r.ttl, + bytes: decodeDnsTxtBytes(r.data, txtFormat))) + .toList(), + aliases, + this); + } + if (cnames.length != 1 || txt.isNotEmpty) { + throw const DnsAliasAmbiguousException('Ambiguous CNAME owner'); + } + aliases.add(cnames.single); + try { + owner = normalizeDnsName(cnames.single.data); + } on DnsException { + throw const DnsMalformedResponseException('Invalid CNAME target'); + } + } + throw const DnsAliasTooLongException('CNAME chain exceeds owner limit'); + } +} + +/// 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. + 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. +List decodeDnsTxtBytes(String value, DnsTxtFormat format, + {int maxPresentationLength = 8192}) { + if (value.length > maxPresentationLength) { + throw const DnsResponseTooLargeException('TXT presentation exceeds limit'); + } + 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); + 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 (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] == ' ') { + i++; + } + if (i == value.length) break; + if (value[i++] != '"') throw malformed; + var closed = false; + while (i < value.length) { + if (value[i] == '"') { + i++; + closed = true; + break; + } + if (value[i] == '\\') { + i++; + if (i == value.length) throw malformed; + bool digit(int at) => + at < value.length && + value.codeUnitAt(at) >= 0x30 && + value.codeUnitAt(at) <= 0x39; + if (digit(i)) { + if (!digit(i + 1) || !digit(i + 2)) throw malformed; + final octet = int.parse(value.substring(i, i + 3)); + if (octet > 255) throw malformed; + bytes.add(octet); + i += 3; + } else { + final pair = value.codeUnitAt(i) & 0xfc00 == 0xd800 && + i + 1 < value.length && + value.codeUnitAt(i + 1) & 0xfc00 == 0xdc00; + final end = i + (pair ? 2 : 1); + bytes.addAll(utf8.encode(value.substring(i, end))); + i = end; + } + } else { + final start = i; + while (i < value.length && value[i] != '"' && value[i] != '\\') { + i++; + } + bytes.addAll(utf8.encode(value.substring(start, i))); + } + } + 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/packages/doh_resolver/lib/src/resolver.dart b/packages/doh_resolver/lib/src/resolver.dart new file mode 100644 index 0000000..7ad4f6f --- /dev/null +++ b/packages/doh_resolver/lib/src/resolver.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:doh_transport/doh_transport.dart'; + +import 'dns.dart'; + +/// 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. +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; + late final Map> _endpointParameters; + + /// Creates a resolver for [endpoint], Google's JSON API by default. + /// + /// 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, + this.timeout = const Duration(seconds: 12), + 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 (!isUsableDohEndpoint(this.endpoint)) { + throw ArgumentError( + 'A credential-free HTTPS resolver endpoint is required'); + } + 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}'); + } + } + + /// Looks up [domain]'s TXT records; see [startTxtLookup]. + Future lookupTxt(String domain) => + startTxtLookup(domain).result; + + /// Starts a cancellable TXT lookup for [domain]. + /// + /// 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); + } on DnsException catch (error) { + return DnsOperation.failed(error); + } + // Repeated endpoint parameters survive; the resolver's own keys override. + final parameters = { + ..._endpointParameters, + 'name': '$domain.', + 'type': 'TXT', + 'do': 'true', + 'cd': 'false', + 'edns_client_subnet': '0.0.0.0/0', + 'random_padding': 'X', + }; + final length = + endpoint.replace(queryParameters: parameters).toString().length; + parameters['random_padding'] = 'X' * (1 + (128 - length % 128) % 128); + 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 abort(DnsException error) { + if (result.isCompleted) return; + result.completeError(error); + timer.cancel(); + try { + operation.cancel(); + } catch (_) {} + } + + timer = Timer( + timeout, () => abort(const DnsTimeoutException('DNS query timed out'))); + operation.result.then((response) { + if (result.isCompleted) return; + try { + if (response.statusCode != 200) { + throw DnsTransportException('DNS HTTPS request failed', + httpStatus: response.statusCode); + } + if (response.body.length > maxResponseBytes) { + throw const DnsResponseTooLargeException('DNS body exceeds limit'); + } + final body = jsonDecode(utf8.decode(response.body)); + result.complete( + DnsResponse.fromJson(body, resolver: endpoint, age: response.age) + .authenticatedTxt(domain, txtFormat: txtFormat)); + } on DnsException catch (error, stack) { + result.completeError(error, stack); + } on FormatException catch (_, stack) { + result.completeError( + const DnsMalformedResponseException('Invalid DNS JSON encoding'), + stack); + } catch (_, stack) { + result.completeError( + const DnsMalformedResponseException('Unreadable DNS response'), + stack); + } finally { + timer.cancel(); + } + }, onError: (Object error, StackTrace stack) { + timer.cancel(); + if (!result.isCompleted) { + result.completeError( + error is DnsException + ? error + : const DnsTransportException('DNS transport failed'), + stack); + } + }); + return DnsOperation(result.future, + () => 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/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/packages/doh_resolver/test/dns_test.dart b/packages/doh_resolver/test/dns_test.dart new file mode 100644 index 0000000..ac55d1e --- /dev/null +++ b/packages/doh_resolver/test/dns_test.dart @@ -0,0 +1,497 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:doh_resolver/doh_resolver.dart'; +import 'package:test/test.dart'; + +Map reply({List>? answers}) => { + 'Status': 0, + 'AD': true, + 'CD': false, + 'TC': false, + 'Question': [ + {'name': 'alice.example.', 'type': 16} + ], + 'Answer': answers ?? + [rr('alice.example.', 16, 'oa1:xmr recipient_address=valid;')], + }; +Map rr(String name, int type, String data) => { + 'name': name, + 'type': type, + 'TTL': 60, + 'data': data, + }; +final resolver = Uri.parse('https://dns.example/resolve'); +DnsResponse decode(Object? json) => + DnsResponse.fromJson(json, resolver: resolver); + +class FakeTransport implements DohTransport { + final completer = Completer(); + bool cancelled = false; + bool throwOnCancel = false; + Uri? uri; + @override + DnsOperation start(Uri uri, + {required int maxResponseBytes}) { + this.uri = uri; + return DnsOperation(completer.future, () { + cancelled = true; + if (throwOnCancel) throw StateError('cancel failed'); + }); + } +} + +void main() { + 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'), + rr('pay.example.', 16, 'two'), + rr('evil.example.', 16, 'evil') + ])); + 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)); + expect(result.response, same(response)); + expect(result.records.first.ttl, 60); + // ignore: deprecated_member_use_from_same_package + expect(result, isA()); + }); + for (final field in ['AD', 'CD', 'TC']) { + test('missing or unacceptable $field never authenticates', () { + for (final body in [ + reply()..remove(field), + reply()..[field] = field != 'AD' + ]) { + 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', txtFormat: DnsTxtFormat.raw), + 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), + 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'); + transport.completer.complete(DohHttpResponse(503, [])); + await expectLater( + result, + throwsA(isA() + .having((e) => e.httpStatus, 'httpStatus', 503))); + }); + 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'); + }); + for (final questions in [ + [], + [ + {'name': 'other.example', 'type': 16} + ], + [ + {'name': 'alice.example', 'type': 1} + ], + [ + {'name': 'alice.example', 'type': 16}, + {'name': 'alice.example', 'type': 16} + ] + ]) { + test('rejects mismatched questions $questions', () { + expect( + () => decode(reply()..['Question'] = questions) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), + throwsA(isA())); + }); + } + test('no answers is distinct from DNS failure', () { + expect( + decode(reply()..remove('Answer')) + .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', txtFormat: DnsTxtFormat.raw), + throwsA(isA())); + for (final records in [ + [rr('alice.example', 5, 'pay.example'), rr('alice.example', 16, 'txt')], + [ + rr('alice.example', 5, 'pay.example'), + rr('alice.example', 5, 'pay.example') + ] + ]) { + expect( + () => decode(reply(answers: records)) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), + throwsA(isA())); + } + final chain = [ + rr('alice.example', 5, 'hop0.example'), + for (var i = 0; i < 7; i++) rr('hop$i.example', 5, 'hop${i + 1}.example'), + rr('hop7.example', 16, 'txt') + ]; + expect( + () => decode(reply(answers: chain)) + .authenticatedTxt('alice.example', txtFormat: DnsTxtFormat.raw), + throwsA(isA())); + }); + test('rejects invalid TTLs, names, record data, types and response shape', + () { + for (final body in [ + null, + [], + reply()..['Answer'] = 'bad', + reply()..['AD'] = 'true', + reply(answers: [rr('alice.example', 16, 'x')..['TTL'] = -1]), + reply(answers: [rr('alice.example', 16, 'x')..['TTL'] = 0x100000000]), + reply(answers: [rr('alice.example', 16, 'x')..['data'] = 7]), + reply(answers: [rr('alice.example', 16, 'x')..['type'] = '16']), + reply(answers: [rr('alice.example', 16, 'x')..['type'] = 0x10000]), + reply() + ..['Question'] = [ + {'name': 'alice.example', 'type': -1} + ], + reply(answers: [rr('K.example', 16, 'x')]) + ]) { + expect(() => decode(body), throwsA(isA())); + } + expect( + () => decode(reply( + answers: List.generate(65, (_) => rr('alice.example', 16, 'x')))), + throwsA(isA())); + }); + test('TXT chunks and decimal UTF-8 octets are decoded without loss', () { + expect( + utf8.decode(decodeDnsTxtBytes( + r'"oa1:xmr recipient_" "address=valid;"', DnsTxtFormat.quoted)), + 'oa1:xmr recipient_address=valid;'); + 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())); + }); + 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', txtFormat: DnsTxtFormat.raw); + expect(result.records.single.ttl, ttl); + expect(result.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', + '"one" junk', + r'"\999"', + r'"\12"', + r'"\1"', + r'"\1x2"', + '"trailing\\', + 'lone \ud800', + '"lone \udc00"', + '"\\\ud800"', + '"\ud800 x"', + ]) { + expect(() => decodeDnsTxtBytes(s, DnsTxtFormat.quoted), + throwsA(isA())); + } + expect(() => decodeDnsTxtBytes('a' * 8193, DnsTxtFormat.quoted), + throwsA(isA())); + }); + test('requests privacy flags and propagates the authenticated result', + () async { + final transport = FakeTransport(); + final resolver = DohResolver(transport: transport); + final query = resolver.startTxtLookup('alice.example'); + expect(transport.uri!.queryParameters, { + 'name': 'alice.example.', + 'type': 'TXT', + 'do': 'true', + 'cd': 'false', + 'edns_client_subnet': '0.0.0.0/0', + 'random_padding': matches(r'^X+$'), + }); + expect(transport.uri.toString().length % 128, 0); + transport.completer + .complete(DohHttpResponse(200, utf8.encode(jsonEncode(reply())))); + expect((await query.result).records.single.text, + 'oa1:xmr recipient_address=valid;'); + query.cancel(); + expect(transport.cancelled, isFalse, + reason: 'a completed transport operation is never cancelled'); + }); + 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')) + .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']) { + final transport = FakeTransport(); + DohResolver( + txtFormat: DnsTxtFormat.raw, + transport: transport, + endpoint: Uri.parse('https://dns.example/resolve?ct=x')) + .startTxtLookup(name); + expect(transport.uri!.queryParameters['ct'], 'x'); + expect(transport.uri!.queryParameters['name'], '$name.'); + lengths.add(transport.uri.toString().length); + } + 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('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( + txtFormat: DnsTxtFormat.raw, + 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()), + (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(isA())); + query.cancel(); + }); + for (final cancel in [true, false]) { + test( + '${cancel ? 'cancellation' : 'deadline'} aborts the underlying operation', + () async { + final transport = FakeTransport(); + final resolver = DohResolver( + transport: transport, timeout: const Duration(milliseconds: 30)); + final query = resolver.startTxtLookup('alice.example'); + final failed = expectLater( + query.result, + throwsA(cancel + ? isA() + : isA())); + if (cancel) query.cancel(); + await failed; + expect(transport.cancelled, isTrue); + transport.completer + .complete(DohHttpResponse(200, utf8.encode(jsonEncode(reply())))); + 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('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, []), + DohHttpResponse(200, utf8.encode('bad json')), + DohHttpResponse(200, List.filled(65537, 0)) + ]) { + final transport = FakeTransport(); + final result = + DohResolver(transport: transport).lookupTxt('alice.example'); + transport.completer.complete(response); + await expectLater(result, throwsA(isA())); + 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/packages/doh_resolver/test/example_test.dart b/packages/doh_resolver/test/example_test.dart new file mode 100644 index 0000000..1f87c85 --- /dev/null +++ b/packages/doh_resolver/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/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/packages/doh_resolver/tool/check.dart b/packages/doh_resolver/tool/check.dart new file mode 100644 index 0000000..e964cde --- /dev/null +++ b/packages/doh_resolver/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/pubspec.yaml b/pubspec.yaml index d6314ee..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: ^4.0.0 - pointycastle: ^4.0.0 - test: ^1.25.0 +workspace: + - packages/doh_resolver diff --git a/test/dns_test.dart b/test/dns_test.dart deleted file mode 100644 index 87c9ae5..0000000 --- a/test/dns_test.dart +++ /dev/null @@ -1,295 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:doh_resolver/doh_resolver.dart'; -import 'package:test/test.dart'; - -Map reply({List>? answers}) => { - 'Status': 0, - 'AD': true, - 'CD': false, - 'TC': false, - 'Question': [ - {'name': 'alice.example.', 'type': 16} - ], - 'Answer': answers ?? - [rr('alice.example.', 16, 'oa1:xmr recipient_address=valid;')], - }; -Map rr(String name, int type, String data) => { - 'name': name, - 'type': type, - 'TTL': 60, - 'data': 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(); - bool cancelled = false; - bool throwOnCancel = false; - Uri? uri; - @override - DnsOperation start(Uri uri, - {required int maxResponseBytes}) { - this.uri = uri; - return DnsOperation(completer.future, () { - cancelled = true; - if (throwOnCancel) throw StateError('cancel failed'); - }); - } -} - -void main() { - test('only terminal-owner TXT answers are accepted; evidence retained', () { - final response = decode(reply(answers: [ - rr('alice.example.', 5, 'pay.example.'), - rr('pay.example.', 16, 'one'), - rr('pay.example.', 16, 'two'), - rr('evil.example.', 16, 'evil') - ])); - final result = response.authenticatedTxt('Alice.Example.'); - expect(result.records.map((r) => r.text), ['one', 'two']); - expect(result.canonicalName, 'pay.example'); - expect(result.aliases, hasLength(1)); - expect(result.response, same(response)); - expect(result.records.first.ttl, 60); - expect(result.authentication, DnsAuthentication.resolverAsserted); - }); - for (final field in ['AD', 'CD', 'TC']) { - test('missing or unacceptable $field never authenticates', () { - for (final body in [ - reply()..remove(field), - reply()..[field] = field != 'AD' - ]) { - expect(() => decode(body).authenticatedTxt('alice.example'), - throwsA(error(DnsError.authenticationUnavailable))); - } - }); - } - test('DNS error preserves status without inferring bogus or unsigned', () { - expect( - () => decode(reply()..['Status'] = 2).authenticatedTxt('alice.example'), - throwsA(error(DnsError.queryFailed))); - }); - for (final questions in [ - [], - [ - {'name': 'other.example', 'type': 16} - ], - [ - {'name': 'alice.example', 'type': 1} - ], - [ - {'name': 'alice.example', 'type': 16}, - {'name': 'alice.example', 'type': 16} - ] - ]) { - test('rejects mismatched questions $questions', () { - expect( - () => decode(reply()..['Question'] = questions) - .authenticatedTxt('alice.example'), - throwsA(error(DnsError.questionMismatch))); - }); - } - test('no answers is distinct from DNS failure', () { - expect( - decode(reply()..remove('Answer')) - .authenticatedTxt('alice.example') - .records, - isEmpty); - }); - test('rejects loops, ambiguous owners and excessive chains', () { - expect( - () => decode(reply(answers: [rr('alice.example', 5, 'alice.example')])) - .authenticatedTxt('alice.example'), - throwsA(error(DnsError.aliasLoop))); - for (final records in [ - [rr('alice.example', 5, 'pay.example'), rr('alice.example', 16, 'txt')], - [ - rr('alice.example', 5, 'pay.example'), - rr('alice.example', 5, 'pay.example') - ] - ]) { - expect( - () => - decode(reply(answers: records)).authenticatedTxt('alice.example'), - throwsA(error(DnsError.aliasAmbiguous))); - } - final chain = [ - rr('alice.example', 5, 'hop0.example'), - for (var i = 0; i < 7; i++) rr('hop$i.example', 5, 'hop${i + 1}.example'), - rr('hop7.example', 16, 'txt') - ]; - expect( - () => decode(reply(answers: chain)).authenticatedTxt('alice.example'), - throwsA(error(DnsError.aliasTooLong))); - }); - test('rejects invalid TTLs, names, record data, types and response shape', - () { - for (final body in [ - null, - [], - reply()..['Answer'] = 'bad', - reply()..['AD'] = 'true', - reply(answers: [rr('alice.example', 16, 'x')..['TTL'] = -1]), - reply(answers: [rr('alice.example', 16, 'x')..['TTL'] = 0x100000000]), - reply(answers: [rr('alice.example', 16, 'x')..['data'] = 7]), - reply(answers: [rr('alice.example', 16, 'x')..['type'] = '16']), - reply(answers: [rr('alice.example', 16, 'x')..['type'] = 0x10000]), - reply() - ..['Question'] = [ - {'name': 'alice.example', 'type': -1} - ], - reply(answers: [rr('K.example', 16, 'x')]) - ]) { - expect(() => decode(body), throwsA(isA())); - } - expect( - () => decode(reply( - answers: List.generate(65, (_) => rr('alice.example', 16, 'x')))), - throwsA(error(DnsError.responseTooLarge))); - }); - test('TXT chunks and decimal UTF-8 octets are decoded without loss', () { - expect( - utf8.decode( - decodeDnsTxtBytes(r'"oa1:xmr recipient_" "address=valid;"')), - '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(() => DnsTxtRecord(name: 'x', ttl: 1, bytes: [255]).text, - throwsA(error(DnsError.malformedResponse))); - }); - 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(error(DnsError.malformedResponse))); - } - 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, - 0xffffffff); - }); - test('malformed and overlong TXT cannot turn into recipient data', () { - for (final s in [ - '"unterminated', - '"one" junk', - r'"\999"', - r'"\12"', - r'"\1"', - r'"\1x2"', - '"trailing\\' - ]) { - expect(() => decodeDnsTxtBytes(s), - throwsA(error(DnsError.malformedResponse))); - } - expect(() => decodeDnsTxtBytes('a' * 8193), - throwsA(error(DnsError.responseTooLarge))); - }); - 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(error(DnsError.invalidName))); - } - }); - test('requests privacy flags and propagates evidence', () async { - final transport = FakeTransport(); - final resolver = DohResolver(transport: transport); - final query = resolver.startTxtLookup('alice.example'); - expect(transport.uri!.queryParameters, { - 'name': 'alice.example.', - 'type': 'TXT', - 'do': 'true', - 'cd': 'false', - 'edns_client_subnet': '0.0.0.0/0', - 'random_padding': matches(r'^X+$'), - }); - expect(transport.uri.toString().length % 128, 0); - transport.completer - .complete(DohHttpResponse(200, utf8.encode(jsonEncode(reply())))); - expect((await query.result).records.single.text, - 'oa1:xmr recipient_address=valid;'); - expect(transport.cancelled, isTrue); - }); - test('padding hides name length and keeps endpoint parameters', () { - final lengths = {}; - for (final name in ['a.example', 'abcd.example']) { - final transport = FakeTransport(); - DohResolver( - transport: transport, - endpoint: Uri.parse('https://dns.example/resolve?ct=x')) - .startTxtLookup(name); - expect(transport.uri!.queryParameters['ct'], 'x'); - expect(transport.uri!.queryParameters['name'], '$name.'); - lengths.add(transport.uri.toString().length); - } - expect(lengths, {128}); - }); - 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))); - query.cancel(); - }); - for (final cancel in [true, false]) { - test( - '${cancel ? 'cancellation' : 'deadline'} aborts the underlying operation', - () async { - final transport = FakeTransport(); - 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))); - if (cancel) query.cancel(); - await failed; - expect(transport.cancelled, isTrue); - transport.completer - .complete(DohHttpResponse(200, utf8.encode(jsonEncode(reply())))); - await Future.delayed(Duration.zero); - }); - } - test('rejects HTTP, JSON and injected response limit violations', () async { - for (final response in [ - DohHttpResponse(302, []), - DohHttpResponse(200, utf8.encode('bad json')), - DohHttpResponse(200, List.filled(65537, 0)) - ]) { - final transport = FakeTransport(); - final result = - DohResolver(transport: transport).lookupTxt('alice.example'); - transport.completer.complete(response); - await expectLater(result, throwsA(isA())); - expect(transport.cancelled, isTrue); - } - }); -} diff --git a/test/io_connection_reuse_test.dart b/test/io_connection_reuse_test.dart deleted file mode 100644 index 590f156..0000000 --- a/test/io_connection_reuse_test.dart +++ /dev/null @@ -1,382 +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 error; -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) => - transport.startWire( - Uri.parse('https://localhost:${server.port}/dns-query'), - wireQuery(id: id), - 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', - DnsError.transport, - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse([], status: 404)) - ), - ( - 'a JSON-typed reply', - DnsError.malformedResponse, - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse(wireReply(id: e.id), type: 'application/json')) - ), - ( - 'a buffered reply with another ID', - DnsError.questionMismatch, - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse(wireReply(id: e.id ^ 1))) - ), - ( - 'a chunked reply with another ID', - DnsError.questionMismatch, - false, - (ScriptedExchange e, Completer _) async => - e.send(httpResponse(wireReply(id: e.id ^ 1), chunked: true)) - ), - ( - 'a chunked reply over the size limit', - DnsError.responseTooLarge, - 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, - 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', - DnsError.cancelled, - 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', - DnsError.cancelled, - 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(error(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); - final b = start(t, 2); - final aFailed = expectLater(a.result, throwsA(error(DnsError.cancelled))); - await until(() => server.exchanges.length == 2); - final held = server.exchanges.firstWhere((e) => e.id == 2); - a.cancel(); - await aFailed; - await until(() => server.closed == 1); - answer(held); - 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); - }); - } - - 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( - 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('a wire transaction ID is never reused on one connection', () 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, 0x1111).result).body[3] & 0xf, 3); - expect((await start(t, 0x1111).result).body[3] & 0xf, 0); - expect(server.connections, 2); - }); - - 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('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(error(DnsError.cancelled))) - ]; - 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))); - 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(error(DnsError.cancelled))); - 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); - }); -} diff --git a/test/io_transport_test.dart b/test/io_transport_test.dart deleted file mode 100644 index 7087a07..0000000 --- a/test/io_transport_test.dart +++ /dev/null @@ -1,216 +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, error; -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(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( - endpoint: endpoint(), - transport: IoDohTransport( - securityContext: trust, - proxy: SocksProxy(InternetAddress.loopbackIPv4, proxy.port))); - await expectLater(resolver.lookupTxt('alice.example'), - throwsA(error(DnsError.transport))); - 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(endpoint: endpoint(), transport: IoDohTransport()) - .lookupTxt('alice.example'), - throwsA(error(DnsError.transport))); - }); - 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( - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(error(DnsError.transport))); - 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( - endpoint: endpoint(), - maxResponseBytes: 128, - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(error(DnsError.responseTooLarge))); - }); - 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( - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(error(DnsError.authenticationUnavailable))); - }); - 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( - 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(error(cancel ? DnsError.cancelled : DnsError.timedOut))); - 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( - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'); - expect(result.records, hasLength(1)); - }); - 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( - endpoint: endpoint(), - transport: IoDohTransport(securityContext: trust)) - .lookupTxt('alice.example'), - throwsA(error(DnsError.malformedResponse))); - }); - } -} diff --git a/test/io_wire_transport_test.dart b/test/io_wire_transport_test.dart deleted file mode 100644 index 32205bb..0000000 --- a/test/io_wire_transport_test.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'dart:async'; -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'; - -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(0, query.length - 2), - query.sublist(0, query.length - 2)); - 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, query); - }); - - test('rejects invalid wire queries before connecting', () { - final transport = IoDohTransport(securityContext: pki.clientContext()); - for (final query in [ - [], - wireReply(), - [...wireQuery(), 0] - ]) { - expect( - () => transport.startWire(endpoint(), query, maxResponseBytes: 512), - throwsArgumentError); - } - }); - - for (final (name, code, status, type, reply) in [ - ( - 'mismatched ID', - DnsError.questionMismatch, - 200, - 'application/dns-message', - (List q) => wireReply(id: (q[0] << 8 | q[1]) ^ 1) - ), - ( - 'another question', - DnsError.questionMismatch, - 200, - 'application/dns-message', - (List q) => wireReply(name: 'b.example', id: q[0] << 8 | q[1]) - ), - ( - 'truncated reply', - DnsError.malformedResponse, - 200, - 'application/dns-message', - (List q) => wireReply(id: q[0] << 8 | q[1], flags: 0x8380) - ), - ( - 'JSON content type', - DnsError.malformedResponse, - 200, - 'application/json', - (List q) => wireReply(id: q[0] << 8 | q[1]) - ), - ( - 'server error', - DnsError.transport, - 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(error(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(error(DnsError.timedOut))); - 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 { - final held = []; - var arrivals = 0; - server.listen((request) { - arrivals++; - held.add(request); - }); - 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); - final operations = [ - for (var id = 1; id <= 4; id++) - transport.startWire(endpoint(), wireQuery(id: id), - 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], error(DnsError.cancelled)); - operations[0].cancel(); - expect(await outcomes[0], error(DnsError.cancelled)); - await until(() => arrivals == 3); - for (final request in held.skip(1)) { - 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])); - 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(error(DnsError.transport))); - final cancelledResult = - expectLater(cancelled.result, throwsA(error(DnsError.cancelled))); - 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 8f2b3cd..0000000 --- a/test/live_test.dart +++ /dev/null @@ -1,50 +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.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 bf5d8d1..0000000 --- a/test/local_evidence_test.dart +++ /dev/null @@ -1,92 +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('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'); - expect(result.authentication, DnsAuthentication.resolverAsserted); - expect(result.validator, isNull); - }); -} 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 5730356..0000000 --- a/test/support/dns_wire.dart +++ /dev/null @@ -1,83 +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)]; 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 96e0819..0000000 --- a/test/wire_test.dart +++ /dev/null @@ -1,292 +0,0 @@ -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]; - -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('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(error(DnsError.questionMismatch))); - }); - - 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(error(DnsError.questionMismatch))); - } - }); - - 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(error(DnsError.malformedResponse))); - } - expect(() => validateDnsWireResponse(wireReply(), wireReply()), - throwsArgumentError); - }); - - 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('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), throwsArgumentError); - } - }); - }); -} - -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')), - ]); -} 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