the-mews/app/lib/protocols/tofu.dart

57 lines
2 KiB
Dart
Raw Permalink Normal View History

/// 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;
}