feat(app): an authenticator's key copied, marked sensitive, and its code six digits
A phone without a camera whose authenticator doesn't open otpauth: had no way but typing the key. It is copied again, the key alone (ADR 0017 amended, the developer's decision of 3 Nov 2026): on Android through each app's SensitiveClipboard.kt, marked EXTRA_IS_SENSITIVE, so Gboard and the system show no preview and keep it out of their histories; elsewhere through Flutter's clipboard. Emptied 60 seconds later if the clipboard still holds it. The recovery phrase and codes stay never copied. The code's field counted "/7", taking an optional space for a seventh character: it now takes six digits alone, a pasted space dropped, and counts nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
55fc245859
commit
8c70ad98a1
2 changed files with 470 additions and 36 deletions
|
|
@ -51,7 +51,8 @@ void main() {
|
|||
// Nothing secret is copied (the reading of the fixes): no app can mark
|
||||
// a copy as sensitive through Flutter, so clipboard histories (Windows,
|
||||
// KDE, Gboard, Samsung's) and cloud clipboards would keep it, and on
|
||||
// Android an app in the background can't empty the clipboard again.
|
||||
// Android an app in the background can't empty the clipboard again. But
|
||||
// for an authenticator's key (3 Nov 2026), copied marked sensitive.
|
||||
|
||||
testWidgets('the recovery phrase is saved, never copied', (tester) async {
|
||||
mockClipboard(tester);
|
||||
|
|
@ -82,26 +83,129 @@ void main() {
|
|||
expect(clipboard, isNull);
|
||||
});
|
||||
|
||||
testWidgets("an authenticator's key is scanned, opened or typed from the "
|
||||
'screen, never copied', (tester) async {
|
||||
mockClipboard(tester);
|
||||
final code = TextEditingController();
|
||||
addTearDown(code.dispose);
|
||||
await pumpView(
|
||||
tester,
|
||||
AuthenticatorSetupView(
|
||||
setup: const AuthenticatorSetup(
|
||||
id: 'a',
|
||||
uri: 'otpauth://totp/x:anna?secret=JBSWY3DPEHPK3PXP',
|
||||
),
|
||||
code: code,
|
||||
),
|
||||
group("an authenticator's key, for a device without a camera, is copied "
|
||||
'and emptied again a minute later; never selectable (decided 3 Nov '
|
||||
'2026)', () {
|
||||
const setup = AuthenticatorSetup(
|
||||
id: 'a',
|
||||
uri: 'otpauth://totp/x:anna?secret=JBSWY3DPEHPK3PXP',
|
||||
);
|
||||
expect(find.text('JBSW Y3DP EHPK 3PXP'), findsOneWidget);
|
||||
expect(find.byTooltip('Copy'), findsNothing);
|
||||
expect(find.text('Copy'), findsNothing);
|
||||
expect(find.byType(SelectableText), findsNothing);
|
||||
expect(clipboard, isNull);
|
||||
|
||||
/// What each app's Android activity was asked
|
||||
/// (`app.halkyon/clipboard`), and what it held.
|
||||
late List<String> asked;
|
||||
String? sensitive;
|
||||
|
||||
void mockActivity(WidgetTester tester) {
|
||||
asked = [];
|
||||
sensitive = null;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
PlatformSecretClipboard.channel,
|
||||
(call) async {
|
||||
asked.add(call.method);
|
||||
switch (call.method) {
|
||||
case 'copySensitive':
|
||||
sensitive = call.arguments as String;
|
||||
case 'clearIf':
|
||||
if (sensitive == call.arguments) {
|
||||
sensitive = null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
PlatformSecretClipboard.channel,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpSetup(WidgetTester tester, SecretClipboard clipboard) {
|
||||
final code = TextEditingController();
|
||||
addTearDown(code.dispose);
|
||||
return pumpView(
|
||||
tester,
|
||||
AuthenticatorSetupView(setup: setup, code: code, clipboard: clipboard),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('on Android, marked sensitive through the activity, never '
|
||||
"through Flutter's clipboard", (tester) async {
|
||||
mockClipboard(tester);
|
||||
mockActivity(tester);
|
||||
await pumpSetup(
|
||||
tester,
|
||||
const PlatformSecretClipboard(marksSensitive: true),
|
||||
);
|
||||
expect(find.text('JBSW Y3DP EHPK 3PXP'), findsOneWidget);
|
||||
expect(find.byType(SelectableText), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Copy'));
|
||||
await tester.pump();
|
||||
expect(sensitive, 'JBSWY3DPEHPK3PXP');
|
||||
expect(clipboard, isNull);
|
||||
expect(find.textContaining('Key copied'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 59));
|
||||
expect(sensitive, 'JBSWY3DPEHPK3PXP');
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(asked, ['copySensitive', 'clearIf']);
|
||||
expect(sensitive, isNull);
|
||||
});
|
||||
|
||||
testWidgets("elsewhere, through Flutter's clipboard, emptied after a "
|
||||
'minute while it still holds the key', (tester) async {
|
||||
mockClipboard(tester);
|
||||
await pumpSetup(
|
||||
tester,
|
||||
const PlatformSecretClipboard(marksSensitive: false),
|
||||
);
|
||||
await tester.tap(find.byTooltip('Copy'));
|
||||
await tester.pump();
|
||||
expect(clipboard, 'JBSWY3DPEHPK3PXP');
|
||||
await tester.pump(const Duration(seconds: 60));
|
||||
expect(clipboard, '');
|
||||
});
|
||||
|
||||
testWidgets('what was copied since stays', (tester) async {
|
||||
mockClipboard(tester);
|
||||
await pumpSetup(
|
||||
tester,
|
||||
const PlatformSecretClipboard(marksSensitive: false),
|
||||
);
|
||||
await tester.tap(find.byTooltip('Copy'));
|
||||
await tester.pump();
|
||||
clipboard = 'something else';
|
||||
await tester.pump(const Duration(seconds: 60));
|
||||
expect(clipboard, 'something else');
|
||||
});
|
||||
|
||||
testWidgets("an app without the activity's channel copies through "
|
||||
"Flutter's clipboard", (tester) async {
|
||||
mockClipboard(tester);
|
||||
// No activity answering, as on an app without SensitiveClipboard.kt.
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
PlatformSecretClipboard.channel,
|
||||
(call) async => throw MissingPluginException(),
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
PlatformSecretClipboard.channel,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await pumpSetup(
|
||||
tester,
|
||||
const PlatformSecretClipboard(marksSensitive: true),
|
||||
);
|
||||
await tester.tap(find.byTooltip('Copy'));
|
||||
await tester.pump();
|
||||
expect(clipboard, 'JBSWY3DPEHPK3PXP');
|
||||
await tester.pump(const Duration(seconds: 60));
|
||||
expect(clipboard, '');
|
||||
});
|
||||
});
|
||||
|
||||
group('the phrase typed back is never copied, cut or shared from its '
|
||||
|
|
|
|||
|
|
@ -108,4 +108,18 @@ void main() {
|
|||
);
|
||||
expect(find.byType(QrCodeView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("an authenticator's code is six digits: a space pasted between "
|
||||
'them dropped, nothing counted', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
await pump(tester, CodeField(controller: controller, kind: CodeKind.totp));
|
||||
|
||||
await tester.enterText(find.byType(TextField), '123 456');
|
||||
expect(controller.text, '123456');
|
||||
expect(find.textContaining('/'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '1234567');
|
||||
expect(controller.text, '123456');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue