feat: add Gemini and Spartan protocol support
This commit is contained in:
parent
a4a49f41d7
commit
fa7aa5557c
48 changed files with 4992 additions and 161 deletions
216
app/lib/protocols/gemini.dart
Normal file
216
app/lib/protocols/gemini.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
129
app/lib/protocols/spartan.dart
Normal file
129
app/lib/protocols/spartan.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/// The Spartan protocol client: plaintext TCP on port 300, a request line of
|
||||
/// `<host> <path> <content-length>` CRLF optionally followed by that many
|
||||
/// bytes of body, and a response of `<status> <meta>` CRLF plus a body on
|
||||
/// success. There is no TLS, so no identity and no trust to decide: anyone
|
||||
/// on the line can read or write the conversation.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// The whole response of one request, redirects already followed.
|
||||
class SpartanResponse {
|
||||
const SpartanResponse({
|
||||
required this.url,
|
||||
required this.status,
|
||||
required this.meta,
|
||||
this.body,
|
||||
});
|
||||
|
||||
/// The address the content finally came from, after redirects.
|
||||
final Uri url;
|
||||
|
||||
/// The single-digit status code: 2 success, 3 redirect, 4 client error,
|
||||
/// 5 server error.
|
||||
final int status;
|
||||
|
||||
/// The MIME type on success, the target address on a redirect, an error
|
||||
/// message otherwise.
|
||||
final String meta;
|
||||
|
||||
/// The response body, present on a success.
|
||||
final Uint8List? body;
|
||||
|
||||
bool get isSuccess => status == 2;
|
||||
bool get isRedirect => status == 3;
|
||||
}
|
||||
|
||||
/// A connection, protocol or timeout failure. One failure to the reader.
|
||||
class SpartanException implements Exception {
|
||||
const SpartanException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// The same address redirected more times than the client follows.
|
||||
class SpartanTooManyRedirects implements Exception {
|
||||
const SpartanTooManyRedirects();
|
||||
}
|
||||
|
||||
class SpartanClient {
|
||||
SpartanClient({this.timeout = const Duration(seconds: 20)});
|
||||
|
||||
static const defaultPort = 300;
|
||||
static const maxRedirects = 5;
|
||||
|
||||
final Duration timeout;
|
||||
|
||||
/// Fetches [url], following status 3 redirects up to [maxRedirects] times.
|
||||
/// [upload] becomes the request body, the protocol's only way of sending
|
||||
/// data to a server.
|
||||
Future<SpartanResponse> fetch(Uri url, {Uint8List? upload}) async {
|
||||
var current = url;
|
||||
for (var hops = 0;; hops++) {
|
||||
final response = await _requestOnce(current, upload);
|
||||
if (!response.isRedirect) return response;
|
||||
if (hops >= maxRedirects) throw const SpartanTooManyRedirects();
|
||||
final target = current.resolve(response.meta);
|
||||
// Only spartan 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 != 'spartan' || target.host.isEmpty) {
|
||||
return response;
|
||||
}
|
||||
current = target;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SpartanResponse> _requestOnce(Uri url, Uint8List? upload) async {
|
||||
final host = url.host;
|
||||
final port = url.hasPort ? url.port : defaultPort;
|
||||
final path = url.path.isEmpty ? '/' : url.path + (url.hasQuery ? '?${url.query}' : '');
|
||||
|
||||
final Socket socket;
|
||||
try {
|
||||
socket = await Socket.connect(host, port, timeout: timeout);
|
||||
} catch (_) {
|
||||
throw const SpartanException('could not connect');
|
||||
}
|
||||
|
||||
try {
|
||||
final length = upload?.length ?? 0;
|
||||
socket.write('$host $path $length\r\n');
|
||||
if (upload != null) socket.add(upload);
|
||||
await socket.flush();
|
||||
|
||||
final raw = await _readToEnd(socket).timeout(timeout);
|
||||
final headerEnd = _indexOfCrlf(raw);
|
||||
if (headerEnd < 1) throw const SpartanException('response without a status line');
|
||||
final header = String.fromCharCodes(raw.sublist(0, headerEnd));
|
||||
if (header.length < 3 || header.codeUnitAt(1) != 0x20) {
|
||||
throw const SpartanException('malformed status line');
|
||||
}
|
||||
final status = header.codeUnitAt(0) - 0x30;
|
||||
if (status < 0 || status > 9) throw const SpartanException('malformed status line');
|
||||
final body = status == 2 ? Uint8List.fromList(raw.sublist(headerEnd + 2)) : null;
|
||||
return SpartanResponse(url: url, status: status, meta: header.substring(2), body: body);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
56
app/lib/protocols/tofu.dart
Normal file
56
app/lib/protocols/tofu.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/// Trust-on-first-use pinning for the Gemini protocol. Gemini hosts use
|
||||
/// self-signed certificates with no authority behind them, so the client
|
||||
/// itself decides what to trust: the first visit pins the server
|
||||
/// certificate's fingerprint, and later visits compare against that pin.
|
||||
///
|
||||
/// A fingerprint change alone is ambiguous: it is the routine path when an
|
||||
/// operator renews an expired certificate and the alarm path when someone
|
||||
/// else is on the line. The decision below is expiry-aware, so the common
|
||||
/// renewal never interrupts the reader while an unexplained change, one that
|
||||
/// happens while the pinned certificate is still valid, does.
|
||||
library;
|
||||
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A pinned server certificate: its SHA-256 fingerprint over the DER
|
||||
/// encoding, and the certificate's own end of validity, which tells a later
|
||||
/// change from a renewal.
|
||||
class TofuPin {
|
||||
const TofuPin({required this.fingerprint, required this.expires});
|
||||
|
||||
/// Sixty-four lowercase hex characters.
|
||||
final String fingerprint;
|
||||
final DateTime expires;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is TofuPin && other.fingerprint == fingerprint;
|
||||
|
||||
@override
|
||||
int get hashCode => fingerprint.hashCode;
|
||||
}
|
||||
|
||||
/// The pin of a DER-encoded certificate.
|
||||
TofuPin pinOf(List<int> derCertificate, {required DateTime expires}) =>
|
||||
TofuPin(fingerprint: sha256.convert(derCertificate).toString(), expires: expires);
|
||||
|
||||
/// What the client should do with a presented certificate.
|
||||
enum TofuDecision {
|
||||
/// Nothing is pinned yet; accept and pin what was presented.
|
||||
firstUse,
|
||||
|
||||
/// The presented certificate matches the pin.
|
||||
match,
|
||||
|
||||
/// The pin has expired and the presented certificate replaces it.
|
||||
rePin,
|
||||
|
||||
/// The pin is still valid and the presented certificate is not it.
|
||||
refuse,
|
||||
}
|
||||
|
||||
TofuDecision decideTofu(TofuPin? pin, String fingerprint, DateTime now) {
|
||||
if (pin == null) return TofuDecision.firstUse;
|
||||
if (pin.fingerprint == fingerprint) return TofuDecision.match;
|
||||
return pin.expires.isAfter(now) ? TofuDecision.refuse : TofuDecision.rePin;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue