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
7 changed files with 280 additions and 24 deletions

View file

@ -141,6 +141,7 @@
"passwordPhraseCheckIntro": "Tippe die gefragten Wörter aus deiner Notiz ein.",
"passwordPhraseWord": "Wort {number}",
"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",
"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.",

View file

@ -435,6 +435,7 @@
}
},
"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",
"passwordCreateTitle": "Create an account",
"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.'**
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.
///
/// In en, this message translates to:

View file

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

View file

@ -529,6 +529,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String get passwordPhraseWrong =>
'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
String get passwordPhraseShowAgain => 'Show the phrase again';

View file

@ -1,5 +1,7 @@
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
/// (ADR 0017: three, chosen at random, so it was written down rather than
/// 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();
}
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.
List<String> wordsOf(String phrase) =>
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).
///
/// 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({
required final List<int> positions,
required final List<TextEditingController> controllers,
@ -475,39 +481,122 @@ class const PhraseCheckFields({
final String? errorText,
final VoidCallback? onDone,
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
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final positions = widget.positions;
// Words of the phrase: kept out of screenshots and recent apps too.
return SecretScreen(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < positions.length; i++) ...[
_PasteOnly(
child: TextField(
controller: controllers[i],
enabled: enabled,
autofocus: i == 0,
autocorrect: false,
enableSuggestions: false,
// Nothing typed here is the keyboard's to learn.
enableIMEPersonalizedLearning: false,
contextMenuBuilder: _PasteOnly.menu,
textInputAction: i == positions.length - 1
? TextInputAction.done
: TextInputAction.next,
onSubmitted: i == positions.length - 1
? (_) => onDone?.call()
: null,
maxLength: TextLimits.name,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
labelText: l10n.passwordPhraseWord(positions[i] + 1),
errorText: i == positions.length - 1 ? errorText : null,
),
),
Autocomplete<String>(
textEditingController: widget.controllers[i],
focusNode: _focus[i],
optionsBuilder: (value) => phraseWordsBeginning(value.text),
onSelected: (_) {
setState(() => _judged.add(i));
if (i < _last) {
_focus[i + 1].requestFocus();
}
},
fieldViewBuilder: (context, controller, focusNode, submit) =>
_PasteOnly(
child: ListenableBuilder(
listenable: controller,
builder: (context, _) => TextField(
controller: controller,
focusNode: focusNode,
enabled: widget.enabled,
autofocus: i == 0,
autocorrect: false,
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),
],