Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lib/core/utils/bip39.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,36 @@ class Bip39WordList {
return null;
}
}

/// The letters that can extend [prefix] toward at least one word of
/// [language].
///
/// This is what lets the in-app keyboard shrink to only the keys that keep a
/// word possible: an empty prefix yields every letter some word begins with
/// (so a letter no word starts with is never offered), and a prefix like
/// `'a'` excludes `'a'` itself because no wordlist word starts with `'aa'`.
/// A prefix that is already a complete word with no longer word extending it
/// yields the empty set — the keyboard then offers backspace only.
///
/// Deliberately answered against the **whole** wordlist even when used on the
/// last field: narrowing to the checksum candidates would let an earlier typo
/// that happens to be another valid word hide the real last word, turning the
/// one error the checksum exists to catch into a silently restored wrong
/// wallet. The candidate narrowing stays a guidance-only concern of the
/// suggestion chips.
///
/// A linear scan of the 2048-word list per keystroke is negligible, so no
/// trie is warranted.
static Set<String> allowedNextLetters({
required String prefix,
bip39.Language language = bip39.Language.english,
}) {
final letters = <String>{};
for (final word in language.list) {
if (word.length > prefix.length && word.startsWith(prefix)) {
letters.add(word[prefix.length]);
}
}
return letters;
}
}
9 changes: 9 additions & 0 deletions lib/core/widgets/inputs/labeled_text_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ class LabeledTextInput extends StatelessWidget {
final Function(String)? onChanged;
final int? maxLines;

/// Both default to true. Set them false for secrets: the IME's suggestion
/// and autocorrect caches must never see the value.
final bool enableSuggestions;
final bool autocorrect;

const LabeledTextInput({
super.key,
required this.label,
required this.value,
required this.onChanged,
this.hint = '',
this.maxLines,
this.enableSuggestions = true,
this.autocorrect = true,
});

@override
Expand Down Expand Up @@ -64,6 +71,8 @@ class LabeledTextInput extends StatelessWidget {
hint: hint,
hideBorder: true,
maxLines: maxLines,
enableSuggestions: enableSuggestions,
autocorrect: autocorrect,
),
),
],
Expand Down
9 changes: 9 additions & 0 deletions lib/core/widgets/inputs/text_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class BBInputText extends StatefulWidget {
this.onlyPaste = false,
this.onlyNumbers = false,
this.obscure = false,
this.enableSuggestions = true,
this.autocorrect = true,
this.style,
this.hideBorder = false,
this.maxLines,
Expand All @@ -43,6 +45,11 @@ class BBInputText extends StatefulWidget {
final bool onlyPaste;
final bool onlyNumbers;
final bool obscure;

/// Both default to true. Set them false for secrets (a BIP39 passphrase):
/// the IME's suggestion and autocorrect caches must never see the value.
final bool enableSuggestions;
final bool autocorrect;
final int? maxLines;
final int? minLines;
final TextStyle? style;
Expand Down Expand Up @@ -117,6 +124,8 @@ class _BBInputTextState extends State<BBInputText> {
obscureText: widget.obscure,
obscuringCharacter: widget.onlyNumbers ? 'x' : '*',
enableIMEPersonalizedLearning: false,
enableSuggestions: widget.enableSuggestions,
autocorrect: widget.autocorrect,
maxLength: widget.maxLength,
minLines: widget.minLines ?? 1,
maxLines: widget.maxLines ?? (widget.obscure ? 1 : null),
Expand Down
301 changes: 301 additions & 0 deletions lib/core/widgets/mnemonic_keyboard.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import 'package:bb_mobile/core/themes/app_theme.dart';
import 'package:bb_mobile/core/widgets/text/text.dart';
import 'package:flutter/material.dart';

/// Letters-only keyboard for mnemonic entry.
///
/// It exists to keep the recovery phrase off the platform IME: on the seed
/// entry screen the word fields are read-only, and this widget is the only
/// path from a tap to a character. A third-party keyboard, the OS
/// autocorrect cache, and any accessibility keylogger therefore never see a
/// keystroke of the seed.
///
/// Deliberately dumb: it knows nothing about BIP39. It renders the 26 letters
/// of [layout] in three rows, enables a key only when its letter is in
/// [enabledLetters], and reports taps through [onLetter] / [onBackspace]. All
/// the wordlist intelligence — which letters keep a word possible, auto fill,
/// focus advance — stays with the owner that computes [enabledLetters].
///
/// [layout] is just the display order: pass [qwerty] for a familiar keyboard,
/// or a shuffled alphabet for the paranoid mode, where randomised key
/// positions defeat shoulder-surfing and tap-position inference.
class MnemonicKeyboard extends StatelessWidget {
/// A familiar QWERTY order, split 10 / 9 / 7 across the three rows.
static const List<String> qwerty = [
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', //
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', //
'z', 'x', 'c', 'v', 'b', 'n', 'm', //
];

/// The 26 lowercase letters in display order. Split 10 / 9 / 7 into rows.
final List<String> layout;

/// The lowercase letters a tap may currently produce. A key outside this set
/// is shown disabled: the owner has determined it cannot extend the word.
final Set<String> enabledLetters;

/// Whether the backspace key is active — false only when the focused field
/// is already empty, so there is nothing to delete.
final bool canBackspace;

final void Function(String letter) onLetter;
final VoidCallback onBackspace;

/// Paranoid mode toggle, shown as a key next to backspace.
final bool shuffleActive;
final VoidCallback onToggleShuffle;

/// Tooltip for the shuffle key. Passed in so this widget stays free of
/// localization.
final String shuffleHint;

const MnemonicKeyboard({
super.key,
required this.enabledLetters,
required this.canBackspace,
required this.onLetter,
required this.onBackspace,
required this.shuffleActive,
required this.onToggleShuffle,
required this.shuffleHint,
this.layout = qwerty,
}) : assert(
layout.length == 26,
'The keyboard lays out exactly 26 keys in three rows (10/9/7); '
'any other length throws a RangeError at build time.',
);

@override
Widget build(BuildContext context) {
return Material(
color: context.appColors.surfaceContainer,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_LetterRow(
letters: layout.sublist(0, 10),
enabledLetters: enabledLetters,
onLetter: onLetter,
paranoid: shuffleActive,
),
_LetterRow(
letters: layout.sublist(10, 19),
enabledLetters: enabledLetters,
onLetter: onLetter,
paranoid: shuffleActive,
),
_LetterRow(
letters: layout.sublist(19, 26),
enabledLetters: enabledLetters,
onLetter: onLetter,
paranoid: shuffleActive,
// Backspace shares its slot with the shuffle toggle
trailing: Row(
children: [
Expanded(
child: _BackspaceKey(
enabled: canBackspace,
onTap: onBackspace,
),
),
Expanded(
child: _ShuffleKey(
active: shuffleActive,
hint: shuffleHint,
onTap: onToggleShuffle,
),
),
],
),
),
],
),
),
),
);
}
}

class _LetterRow extends StatelessWidget {
final List<String> letters;
final Set<String> enabledLetters;
final void Function(String letter) onLetter;
final bool paranoid;
final Widget? trailing;

const _LetterRow({
required this.letters,
required this.enabledLetters,
required this.onLetter,
required this.paranoid,
this.trailing,
});

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
for (final letter in letters)
Expanded(
child: _LetterKey(
letter: letter,
enabled: enabledLetters.contains(letter),
paranoid: paranoid,
onTap: () => onLetter(letter),
),
),
if (trailing != null) Expanded(flex: 2, child: trailing!),
],
),
);
}
}

class _LetterKey extends StatelessWidget {
final String letter;
final bool enabled;
final bool paranoid;
final VoidCallback onTap;

const _LetterKey({
required this.letter,
required this.enabled,
required this.paranoid,
required this.onTap,
});

@override
Widget build(BuildContext context) {
// ExcludeSemantics: the key's letter must not reach the accessibility tree,
// where a malicious accessibility service would read the seed letter by
// letter as it is typed. This makes the keyboard unusable with a screen
// reader by design — the recovery phrase is too sensitive to narrate.
return ExcludeSemantics(
child: _KeyCap(
enabled: enabled,
onTap: onTap,
suppressAnimation: paranoid,
child: BBText(
letter,
style: context.font.headlineLarge,
color: enabled
? context.appColors.onSurface
: context.appColors.textMuted,
),
),
);
}
}

class _BackspaceKey extends StatelessWidget {
final bool enabled;
final VoidCallback onTap;

const _BackspaceKey({required this.enabled, required this.onTap});

@override
Widget build(BuildContext context) {
return _KeyCap(
enabled: enabled,
onTap: onTap,
child: Icon(
Icons.backspace_outlined,
size: 20,
color: enabled
? context.appColors.onSurface
: context.appColors.textMuted,
),
);
}
}

/// Toggles the paranoid, randomised-layout mode. Accent-coloured while active.
class _ShuffleKey extends StatelessWidget {
final bool active;
final String hint;
final VoidCallback onTap;

const _ShuffleKey({
required this.active,
required this.hint,
required this.onTap,
});

@override
Widget build(BuildContext context) {
return Tooltip(
message: hint,
child: _KeyCap(
key: const Key('mnemonicParanoidToggle'),
enabled: true,
onTap: onTap,
child: Icon(
Icons.shuffle,
size: 20,
color: active
? context.appColors.primary
: context.appColors.onSurface,
),
),
);
}
}

/// The shared key shell: sizing, colour, and tap surface. A disabled key has
/// no tap handler at all, so it cannot fire even through automation.
class _KeyCap extends StatelessWidget {
final bool enabled;
final VoidCallback onTap;
final Widget child;

/// When true, the key changes appearance as an instant cut with no ink
/// splash. Used while the layout is reshuffling on every tap: an animated
/// colour fade or a splash that outlives the reshuffle would mark, for a
/// frame, which slot was just pressed — letting an observer follow a letter
/// across the shuffle and defeating it.
final bool suppressAnimation;

const _KeyCap({
super.key,
required this.enabled,
required this.onTap,
required this.child,
this.suppressAnimation = false,
});

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Material(
// Zero duration: even in basic mode an enable/disable colour tween is
// an extra frame of state history for a camera; there is no reason to
// animate a key cap.
animationDuration: Duration.zero,
color: enabled
? context.appColors.surface
: context.appColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
child: InkWell(
// A key must never take focus from the word field being typed into.
canRequestFocus: false,
borderRadius: BorderRadius.circular(6),
splashFactory: suppressAnimation ? NoSplash.splashFactory : null,
highlightColor: suppressAnimation ? Colors.transparent : null,
onTap: enabled ? onTap : null,
child: Container(
height: 44,
alignment: Alignment.center,
child: child,
),
),
),
);
}
}
Loading
Loading