fix(app)!: instances over plain HTTP are refused, but on this machine

What the app sends an instance (the proofs that sign in, the keys'
wrappings, every request a session carries) must not be read or changed on
the way, so an instance is reached over HTTPS, or over plain HTTP only on
this machine (localhost, 127.0.0.0/8, ::1), as Jellyfin servers are. An
address over plain HTTP elsewhere is refused before anything is sent there.
One kept from before is never opened: the app says why, and offers another
instance or removing it from the device, never dropping it silently.
Development builds still use their own instance over HTTP (an emulator
reaches the developer's machine as 10.0.2.2); releases never.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 14:35:21 +02:00
commit 4914df348e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 338 additions and 2 deletions

View file

@ -7,6 +7,10 @@
"instanceAnotherTagline": "Gib ihre Adresse ein",
"instanceAddress": "Adresse der Instanz",
"instanceAddressInvalid": "Das ist keine Adresse.",
"instanceAddressInsecure": "{app} erreicht Instanzen nur über HTTPS, damit unterwegs niemand mitliest oder verändert, was es sendet. Frag nach der HTTPS-Adresse der Instanz.",
"instanceInsecure": "{name} wird über einfaches HTTP erreicht, wo unterwegs mitgelesen und verändert werden könnte, was {app} sendet. Deshalb öffnet {app} es nicht mehr. Das Konto bleibt auf {name} und auf diesem Gerät. Nutze eine andere Instanz oder entferne diese von diesem Gerät.",
"instanceInsecureUseAnother": "Andere Instanz nutzen",
"instanceInsecureRemove": "Von diesem Gerät entfernen",
"instanceAddressRequired": "Gib die Adresse einer Instanz ein.",
"instanceContinue": "Weiter",
"instanceUnreachable": "Unter {name} antwortet keine Instanz von {app}.",

View file

@ -15,6 +15,27 @@
"instanceAnotherTagline": "Enter its address",
"instanceAddress": "Instance address",
"instanceAddressInvalid": "This isn't an address.",
"instanceAddressInsecure": "{app} reaches instances over HTTPS only, so that nobody on the way reads or changes what it sends. Ask for the instance's HTTPS address.",
"@instanceAddressInsecure": {
"placeholders": {
"app": {
"type": "String"
}
}
},
"instanceInsecure": "{name} is reached over plain HTTP, where anyone on the way could read and change what {app} sends, so {app} no longer opens it. Its account stays on {name} and on this device. Use another instance, or remove it from this device.",
"@instanceInsecure": {
"placeholders": {
"name": {
"type": "String"
},
"app": {
"type": "String"
}
}
},
"instanceInsecureUseAnother": "Use another instance",
"instanceInsecureRemove": "Remove from this device",
"instanceAddressRequired": "Enter the address of an instance.",
"instanceContinue": "Continue",
"instanceUnreachable": "No instance of {app} answers at {name}.",

View file

@ -145,6 +145,30 @@ abstract class HalkyonAppLocalizations {
/// **'This isn\'t an address.'**
String get instanceAddressInvalid;
/// No description provided for @instanceAddressInsecure.
///
/// In en, this message translates to:
/// **'{app} reaches instances over HTTPS only, so that nobody on the way reads or changes what it sends. Ask for the instance\'s HTTPS address.'**
String instanceAddressInsecure(String app);
/// No description provided for @instanceInsecure.
///
/// In en, this message translates to:
/// **'{name} is reached over plain HTTP, where anyone on the way could read and change what {app} sends, so {app} no longer opens it. Its account stays on {name} and on this device. Use another instance, or remove it from this device.'**
String instanceInsecure(String name, String app);
/// No description provided for @instanceInsecureUseAnother.
///
/// In en, this message translates to:
/// **'Use another instance'**
String get instanceInsecureUseAnother;
/// No description provided for @instanceInsecureRemove.
///
/// In en, this message translates to:
/// **'Remove from this device'**
String get instanceInsecureRemove;
/// No description provided for @instanceAddressRequired.
///
/// In en, this message translates to:

View file

@ -32,6 +32,22 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get instanceAddressInvalid => 'Das ist keine Adresse.';
@override
String instanceAddressInsecure(String app) {
return '$app erreicht Instanzen nur über HTTPS, damit unterwegs niemand mitliest oder verändert, was es sendet. Frag nach der HTTPS-Adresse der Instanz.';
}
@override
String instanceInsecure(String name, String app) {
return '$name wird über einfaches HTTP erreicht, wo unterwegs mitgelesen und verändert werden könnte, was $app sendet. Deshalb öffnet $app es nicht mehr. Das Konto bleibt auf $name und auf diesem Gerät. Nutze eine andere Instanz oder entferne diese von diesem Gerät.';
}
@override
String get instanceInsecureUseAnother => 'Andere Instanz nutzen';
@override
String get instanceInsecureRemove => 'Von diesem Gerät entfernen';
@override
String get instanceAddressRequired => 'Gib die Adresse einer Instanz ein.';

View file

@ -32,6 +32,22 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get instanceAddressInvalid => 'This isn\'t an address.';
@override
String instanceAddressInsecure(String app) {
return '$app reaches instances over HTTPS only, so that nobody on the way reads or changes what it sends. Ask for the instance\'s HTTPS address.';
}
@override
String instanceInsecure(String name, String app) {
return '$name is reached over plain HTTP, where anyone on the way could read and change what $app sends, so $app no longer opens it. Its account stays on $name and on this device. Use another instance, or remove it from this device.';
}
@override
String get instanceInsecureUseAnother => 'Use another instance';
@override
String get instanceInsecureRemove => 'Remove from this device';
@override
String get instanceAddressRequired => 'Enter the address of an instance.';

View file

@ -11,6 +11,7 @@ import 'package:halkyon_app/src/instance/instance_choice.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/instance/subdivision_names.dart';
import 'package:halkyon_app/src/instance/widget/first_start_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The app on the instance in use ([InstanceChoice]): on a new install
@ -90,8 +91,12 @@ class _InstanceGateState() extends State<InstanceGate> {
void _onChoice() {
final current = widget.choice.current;
if (current != _opened) {
_switching = _switching.then((_) => _open(current));
// One the app no longer allows is never opened: nothing is sent there.
final target = current != null && widget.choice.allows(current)
? current
: null;
if (target != _opened) {
_switching = _switching.then((_) => _open(target));
}
}
@ -145,6 +150,10 @@ class _InstanceGateState() extends State<InstanceGate> {
subdivisionNames: widget.subdivisionNames,
child: switch (current) {
null => PlainApp(look: _look, home: const FirstStartScreen()),
final instance when !widget.choice.allows(instance) => PlainApp(
look: _look,
home: _Insecure(instance: instance, choice: widget.choice),
),
final instance when dependencies != null && _opened == instance =>
AppScope(
key: ValueKey(instance),
@ -189,3 +198,63 @@ class const _OpenFailed({
);
}
}
/// What an instance the app no longer allows shows in place of the app
/// ([InstanceChoice.allows]): why, and the ways on.
class const _Insecure({
required final Instance instance,
required final InstanceChoice choice,
}) extends StatelessWidget {
Future<void> _remove(BuildContext context) async {
final l10n = HalkyonAppLocalizations.of(context);
if (await confirmDestructiveAction(
context,
title: l10n.accountRemoveTitle,
message: l10n.accountRemoveMessage(instance.name),
confirmLabel: l10n.accountRemoveConfirm,
)) {
await choice.leave(instance, forget: true);
}
}
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.instanceInsecure(
instance.name,
AppFrameScope.of(context).name(context),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => choice.leave(instance, forget: false),
child: Text(l10n.instanceInsecureUseAnother),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => _remove(context),
child: Text(l10n.instanceInsecureRemove),
),
],
),
),
),
),
),
);
}
}

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:equatable/equatable.dart';
/// An instance of a Halkyon app: a server people keep their data on, by its
@ -36,6 +38,16 @@ final class const Instance._(
);
}
/// Whether what the app sends there is safe on the way: over HTTPS, or
/// over plain HTTP to this machine alone (`localhost`, `127.0.0.0/8`,
/// `::1`), which never leaves it. Plain HTTP anywhere else would let
/// anyone on the way read and change it: the proofs that sign in, the
/// keys' wrappings, every request a session carries (ADR 0017).
bool get isSecure =>
url.isScheme('https') ||
url.host == 'localhost' ||
(InternetAddress.tryParse(url.host)?.isLoopback ?? false);
/// How people know it: its host, with the port if it has one
/// (`learn.halkyon.eu`, `localhost:8080`).
String get name => url.hasPort ? '${url.host}:${url.port}' : url.host;

View file

@ -17,6 +17,11 @@ class InstanceChoice(
/// Deletes what the device holds of the account on an instance: its
/// people's folder and its session.
required final Future<void> Function(Instance instance) forget,
/// Whether [builtIn] is used even over plain HTTP to another machine: in
/// development builds, whose instance is the developer's own server (an
/// Android emulator reaches it as 10.0.2.2), never in releases.
final bool allowsInsecureBuiltIn = kDebugMode,
}) extends ChangeNotifier {
Instance? _current;
var _known = const <Instance>[];
@ -44,6 +49,27 @@ class InstanceChoice(
notifyListeners();
}
/// Whether the app may open [instance]: reached securely
/// ([Instance.isSecure]), or in a development build the build's own.
/// One kept from before that isn't (an instance added over plain HTTP
/// when the app still took it) stays in the list, never opened, until
/// the person leaves it ([leave]).
bool allows(Instance instance) =>
instance.isSecure || (allowsInsecureBuiltIn && instance == builtIn);
/// Leaves [instance], one the app no longer allows, for the first one it
/// allows (the build's own when there is no other), and if [forget]
/// forgets the account on it as [remove] does.
Future<void> leave(Instance instance, {required bool forget}) async {
final next =
_known.where((each) => each != instance && allows(each)).firstOrNull ??
builtIn;
await use(next);
if (forget) {
await remove(instance);
}
}
/// Uses [instance], keeping its account on the device from now on.
Future<void> use(Instance instance) async {
_current = instance;

View file

@ -64,6 +64,15 @@ class _InstanceStepState() extends State<InstanceStep> {
widget.onChosen(instance);
return;
}
// Refused before anything is sent there (ADR 0017).
if (!instance.isSecure) {
setState(
() => _error = l10n.instanceAddressInsecure(
AppFrameScope.of(context).name(context),
),
);
return;
}
setState(() {
_checking = true;
_error = null;

View file

@ -197,6 +197,58 @@ void main() {
expect(opened, [other]);
});
testWidgets('an instance over plain HTTP is refused before anything is '
'sent there', (tester) async {
await start(tester, answering: {other});
await tester.tap(find.text('Another instance'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'http://learn.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(
find.textContaining('reaches instances over HTTPS only'),
findsOneWidget,
);
expect(opened, isEmpty);
});
testWidgets('an instance kept from before over plain HTTP is never opened: '
'another is used, or it is removed', (tester) async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((
current: insecure,
known: [builtIn, insecure],
));
await start(tester, answering: {builtIn});
expect(opened, isEmpty, reason: 'nothing sent over plain HTTP');
expect(find.textContaining('learn.example.net is reached over'), findsOne);
await tester.tap(find.text('Use another instance'));
await tester.pumpAndSettle();
expect(opened, [builtIn]);
expect(store.kept?.known, [builtIn, insecure], reason: 'kept, unused');
expect(forgotten, isEmpty);
});
testWidgets('an instance kept from before over plain HTTP can be removed '
'from the device', (tester) async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((current: insecure, known: [insecure]));
await start(tester, answering: {builtIn});
await tester.tap(find.text('Remove from this device'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
await tester.pumpAndSettle();
expect(forgotten, [insecure]);
expect(opened, [builtIn]);
expect(store.kept?.known, [builtIn]);
});
testWidgets('an install from before goes on without asking', (tester) async {
await start(tester, usedBefore: true);

View file

@ -83,4 +83,69 @@ void main() {
expect(changes, 4);
});
test('allows an instance reached securely, and over plain HTTP elsewhere '
"only the build's own in a development build", () {
final insecure = Instance.parse('http://10.0.2.2:8080')!;
// A development build, as tests run.
final development = InstanceChoice(
store,
builtIn: insecure,
forget: (instance) async {},
);
expect(development.allows(builtIn), isTrue);
expect(
development.allows(Instance.parse('http://localhost:8080')!),
isTrue,
);
expect(development.allows(insecure), isTrue);
expect(
development.allows(Instance.parse('http://learn.example.org')!),
isFalse,
);
final release = InstanceChoice(
store,
builtIn: insecure,
forget: (instance) async {},
allowsInsecureBuiltIn: false,
);
expect(release.allows(insecure), isFalse);
});
test('leaves an instance it no longer allows for another, forgetting it '
'if asked', () async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((
current: insecure,
known: [other, insecure, builtIn],
));
final instances = choice();
await instances.load(usedBefore: false);
expect(instances.current, insecure, reason: 'never dropped silently');
await instances.leave(insecure, forget: false);
expect(instances.current, other, reason: 'the first allowed one');
expect(instances.known, [other, insecure, builtIn]);
expect(forgotten, isEmpty);
await instances.use(insecure);
await instances.leave(insecure, forget: true);
expect(instances.current, other);
expect(instances.known, [other, builtIn]);
expect(forgotten, [insecure]);
expect(store.kept?.known, [other, builtIn]);
});
test("leaving the only one goes to the build's own", () async {
final insecure = Instance.parse('http://learn.example.net')!;
store = MemoryInstanceStore((current: insecure, known: [insecure]));
final instances = choice();
await instances.load(usedBefore: false);
await instances.leave(insecure, forget: true);
expect(instances.current, builtIn);
expect(instances.known, [builtIn]);
expect(forgotten, [insecure]);
});
}

View file

@ -48,4 +48,26 @@ void main() {
Instance.parse('https://LEARN.example.org/'),
);
});
test('is reached securely over HTTPS, or over HTTP on this machine only', () {
for (final address in [
'learn.example.org',
'https://learn.example.org:8443/learn',
'http://localhost:8080',
'http://127.0.0.1:8080',
'http://127.1.2.3',
'http://[::1]:8080',
]) {
expect(Instance.parse(address)!.isSecure, isTrue, reason: address);
}
for (final address in [
'http://learn.example.org',
'http://10.0.2.2:8080',
'http://192.168.1.10:8080',
'http://localhost.example.org',
'http://[::2]',
]) {
expect(Instance.parse(address)!.isSecure, isFalse, reason: address);
}
});
}