fix(app): nothing secret is copied
The reading of the full reading's fixes found that copied recovery codes and authenticator keys still reached clipboard histories (Windows, KDE's Klipper, Gboard, Samsung's) and cloud clipboards, as no Flutter engine marks a copy as sensitive, and that on Android an app in the background can't empty the clipboard again, so the minute's clearing often never happened. As the developer decided: recovery codes are saved, like the phrase; an authenticator's key is scanned as a QR code, opened in the authenticator app, or typed from the screen. copySecret and its texts go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8c910f8cfc
commit
999877d95c
10 changed files with 59 additions and 150 deletions
|
|
@ -82,9 +82,10 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
and the instance swaps anything in; deleting by a code by mail
|
||||
follows `secondFactorWorks`, as the instance tells it, and asks for the
|
||||
address only where the device doesn't know it. Secrets shown stay the
|
||||
person's: the recovery phrase is saved or written down, never copied;
|
||||
recovery codes and an authenticator's key are copied and the clipboard
|
||||
emptied a minute later (`copySecret`); all of them show in a
|
||||
person's: nothing secret is copied (no app can mark a copy as sensitive
|
||||
through Flutter, so clipboard histories and cloud clipboards would keep
|
||||
it): the recovery phrase and codes are saved or written down, an
|
||||
authenticator's key scanned, opened or typed; all of them show in a
|
||||
`SecretScreen`, kept out of screenshots and recents on Android
|
||||
(`FLAG_SECURE`: the app's `MainActivity` answers `app.halkyon/screen_guard`,
|
||||
as Learn's and Todo's `ScreenGuard.kt` do) and covered on iOS while the app
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ export 'src/password/widget/account_setup_sheet.dart';
|
|||
export 'src/password/widget/password_sign_in_sheet.dart';
|
||||
export 'src/password/widget/password_texts.dart';
|
||||
export 'src/password/widget/password_widgets.dart';
|
||||
export 'src/password/widget/secret_clipboard.dart';
|
||||
export 'src/password/widget/secret_screen.dart';
|
||||
export 'src/person/person_folders.dart';
|
||||
export 'src/sync/app_compatibility.dart';
|
||||
|
|
|
|||
|
|
@ -80,8 +80,6 @@
|
|||
"passwordContinue": "Weiter",
|
||||
"passwordBack": "Zurück",
|
||||
"passwordDone": "Fertig",
|
||||
"passwordCopy": "Kopieren",
|
||||
"passwordCopied": "Kopiert.",
|
||||
"passwordSave": "Als Datei speichern",
|
||||
"passwordQrCode": "QR-Code für deine Authenticator-App",
|
||||
"passwordMailCode": "Code aus der E-Mail",
|
||||
|
|
|
|||
|
|
@ -239,8 +239,6 @@
|
|||
"passwordContinue": "Continue",
|
||||
"passwordBack": "Back",
|
||||
"passwordDone": "Done",
|
||||
"passwordCopy": "Copy",
|
||||
"passwordCopied": "Copied.",
|
||||
"passwordSave": "Save as a file",
|
||||
"passwordQrCode": "QR code for your authenticator app",
|
||||
"passwordMailCode": "Code from the email",
|
||||
|
|
|
|||
|
|
@ -583,18 +583,6 @@ abstract class HalkyonAppLocalizations {
|
|||
/// **'Done'**
|
||||
String get passwordDone;
|
||||
|
||||
/// No description provided for @passwordCopy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copy'**
|
||||
String get passwordCopy;
|
||||
|
||||
/// No description provided for @passwordCopied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copied.'**
|
||||
String get passwordCopied;
|
||||
|
||||
/// No description provided for @passwordSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -305,12 +305,6 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordDone => 'Fertig';
|
||||
|
||||
@override
|
||||
String get passwordCopy => 'Kopieren';
|
||||
|
||||
@override
|
||||
String get passwordCopied => 'Kopiert.';
|
||||
|
||||
@override
|
||||
String get passwordSave => 'Als Datei speichern';
|
||||
|
||||
|
|
|
|||
|
|
@ -302,12 +302,6 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordDone => 'Done';
|
||||
|
||||
@override
|
||||
String get passwordCopy => 'Copy';
|
||||
|
||||
@override
|
||||
String get passwordCopied => 'Copied.';
|
||||
|
||||
@override
|
||||
String get passwordSave => 'Save as a file';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
|||
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||
import 'package:halkyon_app/src/password/model/account_authenticator.dart';
|
||||
import 'package:halkyon_app/src/password/model/phrase_check.dart';
|
||||
import 'package:halkyon_app/src/password/widget/secret_clipboard.dart';
|
||||
import 'package:halkyon_app/src/password/widget/secret_screen.dart';
|
||||
import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength;
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -245,43 +244,30 @@ class const SheetProblem(final String text, {super.key})
|
|||
}
|
||||
}
|
||||
|
||||
/// Save for [text], kept as [fileName] when saved, and where [copyable]
|
||||
/// Copy, which empties the clipboard again a minute later ([copySecret]).
|
||||
/// Save for [text], kept as [fileName]. 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.
|
||||
class const KeepTextButtons({
|
||||
required final String text,
|
||||
required final String fileName,
|
||||
final bool copyable = true,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (copyable)
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.copy),
|
||||
label: Text(l10n.passwordCopy),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
await copySecret(text);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text(l10n.passwordCopied)),
|
||||
);
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.download),
|
||||
label: Text(l10n.passwordSave),
|
||||
onPressed: () => AppScope.of(context).fileSaver.save(
|
||||
name: fileName,
|
||||
bytes: utf8.encode('$text\n'),
|
||||
mimeType: 'text/plain',
|
||||
),
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.download),
|
||||
label: Text(l10n.passwordSave),
|
||||
onPressed: () => AppScope.of(context).fileSaver.save(
|
||||
name: fileName,
|
||||
bytes: utf8.encode('$text\n'),
|
||||
mimeType: 'text/plain',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -353,11 +339,10 @@ class const RecoveryPhraseView({required final String phrase, super.key})
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Saved or written down, never copied ([copySecret]).
|
||||
// Saved or written down, never copied ([KeepTextButtons]).
|
||||
KeepTextButtons(
|
||||
text: phrase,
|
||||
fileName: 'halkyon-recovery-phrase.txt',
|
||||
copyable: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -575,29 +560,14 @@ class const AuthenticatorSetupView({
|
|||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
// Not selectable: Copy empties the clipboard again.
|
||||
child: Text(
|
||||
setup.secret,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.passwordCopy,
|
||||
icon: const Icon(LucideIcons.copy),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
await copySecret(setup.secret.replaceAll(' ', ''));
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text(l10n.passwordCopied)),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
// Typed from the screen, never copied or selectable
|
||||
// ([KeepTextButtons]): the QR code and Open in authenticator app
|
||||
// hand it over otherwise.
|
||||
Text(
|
||||
setup.secret,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
if (phone) ...[
|
||||
const SizedBox(height: 8),
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// How long something secret the app copied (recovery codes, an
|
||||
/// authenticator's key) stays on the system's clipboard: long enough to
|
||||
/// paste it into a password manager, short of what clipboard histories and
|
||||
/// other apps reading the clipboard would otherwise get to keep.
|
||||
const secretClipboardLifetime = Duration(seconds: 60);
|
||||
|
||||
/// Puts [text] on the system's clipboard, and [clearAfter] later empties
|
||||
/// the clipboard if it still holds [text] (what was copied since stays).
|
||||
///
|
||||
/// The recovery phrase is never copied (ADR 0017): it opens the account's
|
||||
/// keys, and clipboard histories (Windows, Gboard, Samsung's) and cloud
|
||||
/// clipboard sync would keep it beyond this app's reach; it is saved as a
|
||||
/// file or written down instead.
|
||||
Future<void> copySecret(
|
||||
String text, {
|
||||
Duration clearAfter = secretClipboardLifetime,
|
||||
}) async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
Timer(clearAfter, () => unawaited(_clearIfStill(text)));
|
||||
}
|
||||
|
||||
Future<void> _clearIfStill(String text) async {
|
||||
try {
|
||||
final held = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (held?.text == text) {
|
||||
await Clipboard.setData(const ClipboardData(text: ''));
|
||||
}
|
||||
} on PlatformException {
|
||||
// A clipboard the system won't read or write now: nothing to clear.
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import 'package:flutter/services.dart';
|
||||
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';
|
||||
|
||||
void main() {
|
||||
/// What the system's clipboard holds.
|
||||
String? clipboard;
|
||||
|
||||
/// Stands in for the system's clipboard for [tester].
|
||||
void mockClipboard(WidgetTester tester) {
|
||||
clipboard = null;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async => switch (call.method) {
|
||||
'Clipboard.setData' => () {
|
||||
clipboard = (call.arguments as Map)['text'] as String?;
|
||||
return null;
|
||||
}(),
|
||||
'Clipboard.getData' => {'text': clipboard},
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpView(WidgetTester tester, Widget view) async {
|
||||
tester.view.physicalSize = const Size(412, 1400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
AppScope(
|
||||
dependencies: TestAppDependencies(gateway: FakeAuthGateway()),
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: testLocalizationsDelegates,
|
||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: SingleChildScrollView(child: view)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
testWidgets('the recovery phrase is saved, never copied', (tester) async {
|
||||
mockClipboard(tester);
|
||||
await pumpView(
|
||||
tester,
|
||||
const RecoveryPhraseView(
|
||||
phrase:
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon '
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon '
|
||||
'abandon abandon abandon abandon abandon abandon abandon art',
|
||||
),
|
||||
);
|
||||
expect(find.text('Copy'), findsNothing);
|
||||
expect(find.text('Save as a file'), findsOneWidget);
|
||||
expect(find.byType(SelectableText), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('recovery codes are saved, never copied', (tester) async {
|
||||
mockClipboard(tester);
|
||||
await pumpView(
|
||||
tester,
|
||||
const RecoveryCodesView(codes: ['AAAA-BBBB', 'CCCC-DDDD']),
|
||||
);
|
||||
expect(find.text('Copy'), findsNothing);
|
||||
expect(find.byTooltip('Copy'), findsNothing);
|
||||
expect(find.text('Save as a file'), findsOneWidget);
|
||||
expect(find.byType(SelectableText), findsNothing);
|
||||
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,
|
||||
),
|
||||
);
|
||||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue