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:
Tizian 「ティツィアーン」 2026-11-03 22:07:35 +01:00
commit 8c70ad98a1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 470 additions and 36 deletions

View file

@ -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 '

View file

@ -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');
});
}