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:
parent
18f456eed9
commit
acb291afcb
9 changed files with 280 additions and 24 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue