/// The Gemini protocol client: TLS on port 1965, a request of the absolute /// URL followed by CRLF, and a response of `` CRLF plus a /// body on success. Servers present self-signed certificates, so every /// connection goes through trust-on-first-use pinning (see [tofu.dart]). /// A client certificate may be offered through the [SecurityContext] the /// caller passes; it is the caller's decision, never the client's, because a /// certificate is an identity and the reader decides where to wear it. library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'tofu.dart'; /// The whole response of one request, redirects already followed. class GeminiResponse { const GeminiResponse({ required this.url, required this.status, required this.meta, this.body, this.pinUpdates = const [], }); /// The address the content finally came from, after redirects. final Uri url; /// The two-digit status code, e.g. 20, 31, 60. final int status; final String meta; /// The response body, present on 2x statuses. final Uint8List? body; /// Certificates to pin: one for each host whose pin was created or renewed /// on the way. Empty when every hop matched its existing pin. final List pinUpdates; bool get isSuccess => status >= 20 && status < 30; bool get isRedirect => status >= 30 && status < 40; bool get wantsClientCertificate => status >= 60 && status < 70; } /// A certificate to pin for one host. class TofuUpdate { const TofuUpdate({required this.host, required this.port, required this.pin}); final String host; final int port; final TofuPin pin; @override bool operator ==(Object other) => other is TofuUpdate && other.host == host && other.port == port && other.pin == pin; @override int get hashCode => Object.hash(host, port, pin); } /// A connection, protocol or timeout failure. One failure to the reader. class GeminiException implements Exception { const GeminiException(this.message); final String message; @override String toString() => message; } /// The same address redirected more times than the client follows. class GeminiTooManyRedirects implements Exception { const GeminiTooManyRedirects(); } /// The server's certificate is not the one pinned for it, and the pin has not /// expired. Carries what was presented, so the reader can be offered the /// choice to trust it. class GeminiCertificateChanged implements Exception { const GeminiCertificateChanged({required this.host, required this.port, required this.presented}); final String host; final int port; final TofuPin presented; } class GeminiClient { GeminiClient({this.timeout = const Duration(seconds: 20)}); static const defaultPort = 1965; static const maxRedirects = 5; /// The largest request the protocol allows, URL plus CRLF. static const maxRequestBytes = 1024; final Duration timeout; /// Fetches [url], following 3x redirects up to [maxRedirects] times. /// /// [pins] holds the reader's pinned certificates by `host:port`, which /// every host on the way, redirect targets included, is checked against. Future fetch(Uri url, {Map pins = const {}, SecurityContext? clientCert}) async { final updates = []; var current = url; for (var hops = 0;; hops++) { final response = await _requestOnce(current, pins, clientCert, updates); if (!response.isRedirect) { return GeminiResponse( url: response.url, status: response.status, meta: response.meta, body: response.body, pinUpdates: updates, ); } if (hops >= maxRedirects) throw const GeminiTooManyRedirects(); final target = current.resolve(response.meta); // Only gemini addresses are followed; anything else the redirect points // to is left for the reader to open as its own navigation. if (!target.hasScheme || target.scheme != 'gemini' || target.host.isEmpty) { return response; } current = target; } } Future _requestOnce(Uri url, Map pins, SecurityContext? clientCert, List updates) async { final host = url.host; final port = url.hasPort ? url.port : defaultPort; final request = utf8.encode('${url.toString()}\r\n'); if (request.length > maxRequestBytes) { throw const GeminiException('request longer than the protocol allows'); } final now = DateTime.now(); TofuPin? presentedByRefusal; final SecureSocket socket; try { socket = await SecureSocket.connect(host, port, context: clientCert, timeout: timeout, onBadCertificate: (certificate) { final pin = _pinOf(certificate); final decision = decideTofu(pins['$host:$port'], pin.fingerprint, now); // Refusing during the handshake, before the request or the // reader's own certificate is sent, discloses nothing to whoever // is on the line. if (decision == TofuDecision.refuse) presentedByRefusal = pin; return decision != TofuDecision.refuse; }); } catch (_) { if (presentedByRefusal != null) { throw GeminiCertificateChanged( host: host, port: port, presented: presentedByRefusal!); } throw const GeminiException('could not connect'); } try { final seen = socket.peerCertificate; if (seen != null) { final pin = _pinOf(seen); final decision = decideTofu(pins['$host:$port'], pin.fingerprint, now); // A certificate can also clear the handshake on a public chain, so // the pin is decided here too, not only in the callback above. if (decision == TofuDecision.refuse) { throw GeminiCertificateChanged(host: host, port: port, presented: pin); } if (decision != TofuDecision.match) { updates.add(TofuUpdate(host: host, port: port, pin: pin)); } } socket.add(request); await socket.flush(); final raw = await _readToEnd(socket).timeout(timeout); final headerEnd = _indexOfCrlf(raw); if (headerEnd < 2) throw const GeminiException('response without a status line'); final header = utf8.decode(raw.sublist(0, headerEnd), allowMalformed: true); if (header.length < 3 || header.codeUnitAt(2) != 0x20) { throw const GeminiException('malformed status line'); } final status = int.tryParse(header.substring(0, 2)); if (status == null) throw const GeminiException('malformed status line'); final body = status >= 20 && status < 30 ? Uint8List.fromList(raw.sublist(headerEnd + 2)) : null; return GeminiResponse(url: url, status: status, meta: header.substring(3), body: body); } finally { socket.destroy(); } } /// A certificate as a pin: its fingerprint and its own end of validity. static TofuPin _pinOf(X509Certificate certificate) => pinOf(certificate.der, expires: certificate.endValidity); static Future _readToEnd(Socket socket) async { final builder = BytesBuilder(copy: false); await for (final chunk in socket) { builder.add(chunk); } return builder.toBytes(); } static int _indexOfCrlf(Uint8List bytes) { for (var i = 0; i + 1 < bytes.length; i++) { if (bytes[i] == 0x0d && bytes[i + 1] == 0x0a) return i; } return -1; } }