feat: add Gemini and Spartan protocol support

This commit is contained in:
randogoth 2026-10-11 22:07:25 +03:00
parent a4a49f41d7
commit fa7aa5557c
48 changed files with 4992 additions and 161 deletions

View file

@ -0,0 +1,216 @@
/// The Gemini protocol client: TLS on port 1965, a request of the absolute
/// URL followed by CRLF, and a response of `<STATUS><SPACE><META>` 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<TofuUpdate> 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<GeminiResponse> fetch(Uri url,
{Map<String, TofuPin> pins = const {}, SecurityContext? clientCert}) async {
final updates = <TofuUpdate>[];
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<GeminiResponse> _requestOnce(Uri url, Map<String, TofuPin> pins,
SecurityContext? clientCert, List<TofuUpdate> 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<Uint8List> _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;
}
}