feat(app): the recovery phrase's words typed back are suggested from BIP-39's list (ADR 0017)

Each of the three fields suggests the words of BIP-39's whole English
list beginning with what is typed, never the phrase's own, and refuses a
word not on it as its field is left, so that a typo is caught before the
words are compared.

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-29 22:34:02 +01:00
commit acb291afcb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 280 additions and 24 deletions

View file

@ -712,6 +712,21 @@ screenshots, but not out of ScreenCaptureKit's captures on macOS 15 and later; o
covered in the app switcher's picture, screenshots and recordings not held back; on covered in the app switcher's picture, screenshots and recordings not held back; on
Linux not at all. Linux not at all.
## The words typed back, suggested from the list (29 Oct 2026, the developer's)
Each of the three fields the recovery phrase's words are typed back into suggests the
words of BIP-39's English list that begin with what is typed (at most five, from the
first letter; none once a whole word is typed that no longer one begins with), and
takes no word that isn't on the list: as its field is left, it says so and keeps the
focus there, and the words aren't handed on until every field holds one of the list's.
A typo is so caught before the words are compared, and told apart from a wrong word.
The suggestions come from the whole list of 2,048, never the phrase's 24, which would
tell which words it holds; the keyboard still learns and suggests nothing.
`isPhraseWord` and `phraseWordsBeginning` (halkyon_app's `phrase_check.dart`) decide,
`PhraseCheckFields` shows them, on sign-up, on a new phrase and on SimpleLogin's
sign-up alike.
## Consequences ## Consequences
- People keep a password, an authenticator app and a recovery phrase: more to keep than a - People keep a password, an authenticator app and a recovery phrase: more to keep than a

View file

@ -141,6 +141,7 @@
"passwordPhraseCheckIntro": "Tippe die gefragten Wörter aus deiner Notiz ein.", "passwordPhraseCheckIntro": "Tippe die gefragten Wörter aus deiner Notiz ein.",
"passwordPhraseWord": "Wort {number}", "passwordPhraseWord": "Wort {number}",
"passwordPhraseWrong": "Das sind nicht die Wörter. Sieh noch einmal in deine Notiz.", "passwordPhraseWrong": "Das sind nicht die Wörter. Sieh noch einmal in deine Notiz.",
"passwordPhraseNotAWord": "Kein Wort, aus dem eine Wiederherstellungsphrase besteht",
"passwordPhraseShowAgain": "Phrase noch einmal zeigen", "passwordPhraseShowAgain": "Phrase noch einmal zeigen",
"passwordCreateTitle": "Konto erstellen", "passwordCreateTitle": "Konto erstellen",
"passwordCreateIntro": "Auf {instance}, mit deiner E-Mail-Adresse, einem Passwort und einer Authenticator-App für den Code, den jede Anmeldung braucht.", "passwordCreateIntro": "Auf {instance}, mit deiner E-Mail-Adresse, einem Passwort und einer Authenticator-App für den Code, den jede Anmeldung braucht.",

View file

@ -435,6 +435,7 @@
} }
}, },
"passwordPhraseWrong": "These aren't the words. Look again at what you wrote down.", "passwordPhraseWrong": "These aren't the words. Look again at what you wrote down.",
"passwordPhraseNotAWord": "Not a word a recovery phrase is made of",
"passwordPhraseShowAgain": "Show the phrase again", "passwordPhraseShowAgain": "Show the phrase again",
"passwordCreateTitle": "Create an account", "passwordCreateTitle": "Create an account",
"passwordCreateIntro": "On {instance}, with your email address, a password, and an authenticator app for the code every sign-in takes.", "passwordCreateIntro": "On {instance}, with your email address, a password, and an authenticator app for the code every sign-in takes.",

View file

@ -949,6 +949,12 @@ abstract class HalkyonAppLocalizations {
/// **'These aren\'t the words. Look again at what you wrote down.'** /// **'These aren\'t the words. Look again at what you wrote down.'**
String get passwordPhraseWrong; String get passwordPhraseWrong;
/// No description provided for @passwordPhraseNotAWord.
///
/// In en, this message translates to:
/// **'Not a word a recovery phrase is made of'**
String get passwordPhraseNotAWord;
/// No description provided for @passwordPhraseShowAgain. /// No description provided for @passwordPhraseShowAgain.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -539,6 +539,10 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get passwordPhraseWrong => String get passwordPhraseWrong =>
'Das sind nicht die Wörter. Sieh noch einmal in deine Notiz.'; 'Das sind nicht die Wörter. Sieh noch einmal in deine Notiz.';
@override
String get passwordPhraseNotAWord =>
'Kein Wort, aus dem eine Wiederherstellungsphrase besteht';
@override @override
String get passwordPhraseShowAgain => 'Phrase noch einmal zeigen'; String get passwordPhraseShowAgain => 'Phrase noch einmal zeigen';

View file

@ -529,6 +529,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String get passwordPhraseWrong => String get passwordPhraseWrong =>
'These aren\'t the words. Look again at what you wrote down.'; 'These aren\'t the words. Look again at what you wrote down.';
@override
String get passwordPhraseNotAWord =>
'Not a word a recovery phrase is made of';
@override @override
String get passwordPhraseShowAgain => 'Show the phrase again'; String get passwordPhraseShowAgain => 'Show the phrase again';

View file

@ -1,5 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:halkyon_core/halkyon_core.dart' show bip39English;
/// Which words of a recovery phrase of [length] words the person types back /// Which words of a recovery phrase of [length] words the person types back
/// (ADR 0017: three, chosen at random, so it was written down rather than /// (ADR 0017: three, chosen at random, so it was written down rather than
/// glanced at), in order, by their positions from 0. /// glanced at), in order, by their positions from 0.
@ -12,6 +14,29 @@ List<int> wordsToCheck(int length, {Random? random, int count = 3}) {
return chosen.toList()..sort(); return chosen.toList()..sort();
} }
final _phraseWords = bip39English.toSet();
/// Whether [word] is one a recovery phrase is made of (BIP-39's English
/// list), whatever its case and the spaces around it: a word typed back
/// that isn't is a typo, caught before the words are compared.
bool isPhraseWord(String word) =>
_phraseWords.contains(word.trim().toLowerCase());
/// The words of BIP-39's English list beginning with what [typed] begins,
/// at most [limit], in the list's order; none where [typed] is empty or
/// a whole word no other begins with.
List<String> phraseWordsBeginning(String typed, {int limit = 5}) {
final start = typed.trim().toLowerCase();
if (start.isEmpty) {
return const [];
}
final words = bip39English
.where((word) => word.startsWith(start))
.take(limit)
.toList();
return words.length == 1 && words.single == start ? const [] : words;
}
/// The words of [phrase], in order. /// The words of [phrase], in order.
List<String> wordsOf(String phrase) => List<String> wordsOf(String phrase) =>
phrase.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); phrase.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();

View file

@ -468,6 +468,12 @@ class const PhraseField({
} }
/// Fields for the words of a recovery phrase at [positions] (from 0). /// Fields for the words of a recovery phrase at [positions] (from 0).
///
/// Each field suggests the words of BIP-39's English list beginning with
/// what is typed (`phraseWordsBeginning`), never the phrase's own, a word
/// picked moving on to the next field; a word not on the list is refused
/// as its field is left (`isPhraseWord`), and [onDone] waits until every
/// field holds one.
class const PhraseCheckFields({ class const PhraseCheckFields({
required final List<int> positions, required final List<int> positions,
required final List<TextEditingController> controllers, required final List<TextEditingController> controllers,
@ -475,39 +481,122 @@ class const PhraseCheckFields({
final String? errorText, final String? errorText,
final VoidCallback? onDone, final VoidCallback? onDone,
super.key, super.key,
}) extends StatelessWidget { }) extends StatefulWidget {
@override
State<PhraseCheckFields> createState() => _PhraseCheckFieldsState();
}
class _PhraseCheckFieldsState() extends State<PhraseCheckFields> {
late final List<FocusNode> _focus = [
for (var i = 0; i < widget.positions.length; i++)
FocusNode()..addListener(() => _left(i)),
];
/// The fields left or submitted at least once: their words are judged.
final _judged = <int>{};
int get _last => widget.positions.length - 1;
@override
void dispose() {
for (final node in _focus) {
node.dispose();
}
super.dispose();
}
void _left(int i) {
if (!_focus[i].hasFocus && mounted && _judged.add(i)) {
setState(() {});
}
}
bool _known(int i) => isPhraseWord(widget.controllers[i].text);
/// After field [i] was submitted: kept there with a word not on the
/// list, else on to the next, or done once every word is on it.
void _submitted(int i) {
setState(() => _judged.add(i));
if (!_known(i)) {
_focus[i].requestFocus();
return;
}
if (i < _last) {
_focus[i + 1].requestFocus();
return;
}
final unknown = [
for (var j = 0; j <= _last; j++)
if (!_known(j)) j,
];
if (unknown.isEmpty) {
widget.onDone?.call();
} else {
setState(() => _judged.addAll(unknown));
_focus[unknown.first].requestFocus();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context); final l10n = HalkyonAppLocalizations.of(context);
final positions = widget.positions;
// Words of the phrase: kept out of screenshots and recent apps too. // Words of the phrase: kept out of screenshots and recent apps too.
return SecretScreen( return SecretScreen(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (var i = 0; i < positions.length; i++) ...[ for (var i = 0; i < positions.length; i++) ...[
_PasteOnly( Autocomplete<String>(
child: TextField( textEditingController: widget.controllers[i],
controller: controllers[i], focusNode: _focus[i],
enabled: enabled, optionsBuilder: (value) => phraseWordsBeginning(value.text),
autofocus: i == 0, onSelected: (_) {
autocorrect: false, setState(() => _judged.add(i));
enableSuggestions: false, if (i < _last) {
// Nothing typed here is the keyboard's to learn. _focus[i + 1].requestFocus();
enableIMEPersonalizedLearning: false, }
contextMenuBuilder: _PasteOnly.menu, },
textInputAction: i == positions.length - 1 fieldViewBuilder: (context, controller, focusNode, submit) =>
? TextInputAction.done _PasteOnly(
: TextInputAction.next, child: ListenableBuilder(
onSubmitted: i == positions.length - 1 listenable: controller,
? (_) => onDone?.call() builder: (context, _) => TextField(
: null, controller: controller,
maxLength: TextLimits.name, focusNode: focusNode,
buildCounter: nearLimitCounter, enabled: widget.enabled,
decoration: InputDecoration( autofocus: i == 0,
labelText: l10n.passwordPhraseWord(positions[i] + 1), autocorrect: false,
errorText: i == positions.length - 1 ? errorText : null, enableSuggestions: false,
), // Nothing typed here is the keyboard's to learn.
), enableIMEPersonalizedLearning: false,
contextMenuBuilder: _PasteOnly.menu,
textInputAction: i == _last
? TextInputAction.done
: TextInputAction.next,
// Focus moves as [_submitted] decides.
onEditingComplete: () {},
onSubmitted: (_) {
// A suggestion highlighted is taken first.
submit();
_submitted(i);
},
maxLength: TextLimits.name,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.passwordPhraseWord(positions[i] + 1),
errorText:
_judged.contains(i) &&
controller.text.trim().isNotEmpty &&
!_known(i)
? l10n.passwordPhraseNotAWord
: i == _last
? widget.errorText
: null,
),
),
),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],

View file

@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:material_ui/material_ui.dart';
/// The recovery phrase's words typed back (ADR 0017): each field suggests
/// words of BIP-39's whole English list as they are typed, never the
/// phrase's own, and takes no word that isn't on it, so a typo is caught
/// before the words are compared.
void main() {
late List<TextEditingController> controllers;
late int done;
setUp(() {
controllers = [for (var i = 0; i < 3; i++) TextEditingController()];
done = 0;
});
tearDown(() {
for (final controller in controllers) {
controller.dispose();
}
});
Future<void> pump(WidgetTester tester) => tester.pumpWidget(
MaterialApp(
localizationsDelegates: testLocalizationsDelegates,
supportedLocales: HalkyonAppLocalizations.supportedLocales,
locale: const Locale('en'),
home: Scaffold(
body: SingleChildScrollView(
child: PhraseCheckFields(
positions: const [2, 7, 19],
controllers: controllers,
onDone: () => done++,
),
),
),
),
);
Finder field(int i) => find.byType(TextField).at(i);
testWidgets("the list's words are suggested as they are typed, and one "
'picked fills the field and moves on', (tester) async {
await pump(tester);
await tester.enterText(field(0), 'zo');
await tester.pump();
expect(find.text('zone'), findsOneWidget);
expect(find.text('zoo'), findsOneWidget);
await tester.tap(find.text('zoo'));
await tester.pump();
expect(controllers[0].text, 'zoo');
expect(
tester.widget<TextField>(field(1)).focusNode!.hasFocus,
isTrue,
);
});
testWidgets('a word typed whole suggests nothing more, unless a longer '
'one begins with it', (tester) async {
await pump(tester);
await tester.enterText(field(0), 'zoo');
await tester.pump();
// The field's own text alone, no option below it.
expect(find.text('zoo'), findsOneWidget);
await tester.enterText(field(0), 'act');
await tester.pump();
expect(find.text('action'), findsOneWidget);
});
testWidgets("a word that isn't on the list is refused as the field is "
'left, and the words are not handed on', (tester) async {
await pump(tester);
await tester.enterText(field(0), 'abandom');
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(find.text('Not a word a recovery phrase is made of'), findsOne);
expect(
tester.widget<TextField>(field(0)).focusNode!.hasFocus,
isTrue,
);
await tester.enterText(field(0), 'abandon');
await tester.enterText(field(1), 'Zoo ');
await tester.enterText(field(2), 'qwerty');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(done, 0);
expect(find.text('Not a word a recovery phrase is made of'), findsOne);
await tester.enterText(field(2), 'ability');
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(done, 1);
expect(find.text('Not a word a recovery phrase is made of'), findsNothing);
});
test("a phrase's word is one of BIP-39's English list, whatever its case "
'and the spaces around it', () {
expect(isPhraseWord(' Abandon '), isTrue);
expect(isPhraseWord('zoo'), isTrue);
expect(isPhraseWord('abandom'), isFalse);
expect(isPhraseWord(''), isFalse);
});
}