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
11 changed files with 470 additions and 36 deletions
|
|
@ -150,6 +150,7 @@ 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/privacy/content_cipher.dart';
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@
|
|||
"passwordAuthenticatorTitle": "Authenticator einrichten",
|
||||
"passwordAuthenticatorIntro": "Jede Anmeldung braucht einen Code aus einer Authenticator-App wie Aegis, 2FAS oder Proton Authenticator. Scanne diesen Code damit oder tippe den Schlüssel ein.",
|
||||
"passwordAuthenticatorKey": "Schlüssel",
|
||||
"passwordAuthenticatorKeyCopied": "Schlüssel kopiert. In einer Minute wird er aus der Zwischenablage entfernt.",
|
||||
"passwordAuthenticatorOpen": "In deiner Authenticator-App öffnen",
|
||||
"passwordAuthenticatorNoApp": "Keine App auf diesem Gerät öffnet ihn. Scanne den Code oder tippe den Schlüssel ein.",
|
||||
"passwordAuthenticatorCodeIntro": "Gib dann den Code ein, den sie zeigt.",
|
||||
|
|
|
|||
|
|
@ -413,6 +413,10 @@
|
|||
"passwordAuthenticatorTitle": "Set up an authenticator",
|
||||
"passwordAuthenticatorIntro": "Every sign-in takes a code from an authenticator app, such as Aegis, 2FAS or Proton Authenticator. Scan this code with it, or type in the key.",
|
||||
"passwordAuthenticatorKey": "Key",
|
||||
"passwordAuthenticatorKeyCopied": "Key copied. It's taken off the clipboard again in a minute.",
|
||||
"@passwordAuthenticatorKeyCopied": {
|
||||
"description": "After the authenticator's key was copied during its setup; the app empties the clipboard 60 seconds later."
|
||||
},
|
||||
"passwordAuthenticatorOpen": "Open in your authenticator app",
|
||||
"passwordAuthenticatorNoApp": "No app on this device opens it. Scan the code or type in the key.",
|
||||
"passwordAuthenticatorCodeIntro": "Then enter the code it shows.",
|
||||
|
|
|
|||
|
|
@ -859,6 +859,12 @@ abstract class HalkyonAppLocalizations {
|
|||
/// **'Key'**
|
||||
String get passwordAuthenticatorKey;
|
||||
|
||||
/// After the authenticator's key was copied during its setup; the app empties the clipboard 60 seconds later.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Key copied. It\'s taken off the clipboard again in a minute.'**
|
||||
String get passwordAuthenticatorKeyCopied;
|
||||
|
||||
/// No description provided for @passwordAuthenticatorOpen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -485,6 +485,10 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordAuthenticatorKey => 'Schlüssel';
|
||||
|
||||
@override
|
||||
String get passwordAuthenticatorKeyCopied =>
|
||||
'Schlüssel kopiert. In einer Minute wird er aus der Zwischenablage entfernt.';
|
||||
|
||||
@override
|
||||
String get passwordAuthenticatorOpen => 'In deiner Authenticator-App öffnen';
|
||||
|
||||
|
|
|
|||
|
|
@ -475,6 +475,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get passwordAuthenticatorKey => 'Key';
|
||||
|
||||
@override
|
||||
String get passwordAuthenticatorKeyCopied =>
|
||||
'Key copied. It\'s taken off the clipboard again in a minute.';
|
||||
|
||||
@override
|
||||
String get passwordAuthenticatorOpen => 'Open in your authenticator app';
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,12 @@ final class const AuthenticatorSetup({
|
|||
bool get isAuthenticatorUri =>
|
||||
Uri.tryParse(uri)?.isScheme('otpauth') ?? false;
|
||||
|
||||
/// The secret the URI holds, Base32, as copied.
|
||||
String get rawSecret => Uri.tryParse(uri)?.queryParameters['secret'] ?? '';
|
||||
|
||||
/// The secret the URI holds, Base32, in groups of four to type by hand.
|
||||
String get secret {
|
||||
final raw = Uri.tryParse(uri)?.queryParameters['secret'] ?? '';
|
||||
final raw = rawSecret;
|
||||
return [
|
||||
for (var i = 0; i < raw.length; i += 4)
|
||||
raw.substring(i, i + 4 > raw.length ? raw.length : i + 4),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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';
|
||||
|
|
@ -163,14 +164,16 @@ class const CodeField({
|
|||
textCapitalization: TextCapitalization.characters,
|
||||
textInputAction: textInputAction,
|
||||
onSubmitted: onSubmitted,
|
||||
// Six digits, a space perhaps between them; eight characters, a dash
|
||||
// or space perhaps between them.
|
||||
maxLength: digits ? 7 : 9,
|
||||
buildCounter: nearLimitCounter,
|
||||
// Six digits, a space pasted between them dropped (no counter: six
|
||||
// is what every authenticator shows); eight characters, a dash or
|
||||
// space perhaps between them.
|
||||
maxLength: digits ? 6 : 9,
|
||||
buildCounter: digits ? _noCounter : nearLimitCounter,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
digits ? RegExp('[0-9 ]') : RegExp(r'[0-9A-Za-z\- ]'),
|
||||
),
|
||||
if (digits)
|
||||
FilteringTextInputFormatter.digitsOnly
|
||||
else
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Za-z\- ]')),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText:
|
||||
|
|
@ -190,6 +193,13 @@ class const CodeField({
|
|||
}
|
||||
}
|
||||
|
||||
Widget? _noCounter(
|
||||
BuildContext context, {
|
||||
required int currentLength,
|
||||
required bool isFocused,
|
||||
required int? maxLength,
|
||||
}) => null;
|
||||
|
||||
/// A sheet's step in progress: the spinner, and what is happening.
|
||||
class const SheetWorking({final String? message, super.key})
|
||||
extends StatelessWidget {
|
||||
|
|
@ -742,6 +752,7 @@ class const AuthenticatorSetupView({
|
|||
final String? existingErrorText,
|
||||
final bool enabled = true,
|
||||
final VoidCallback? onDone,
|
||||
final SecretClipboard clipboard = const PlatformSecretClipboard(),
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -766,14 +777,33 @@ class const AuthenticatorSetupView({
|
|||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 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()],
|
||||
),
|
||||
// Not selectable, as the system's own copy would stay; copied
|
||||
// through [clipboard], marked sensitive and emptied again, for a
|
||||
// device without a camera whose authenticator doesn't open it.
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
setup.secret,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.copy),
|
||||
tooltip: MaterialLocalizations.of(context).copyButtonLabel,
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
await clipboard.copy(setup.rawSecret);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.passwordAuthenticatorKeyCopied),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (phone) ...[
|
||||
const SizedBox(height: 8),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Copies an authenticator's key, for a device without a camera whose
|
||||
/// authenticator app doesn't open `otpauth:` (decided 3 Nov 2026, amending
|
||||
/// ADR 0017's "nothing secret is copied" for the key alone): the copy is
|
||||
/// marked sensitive where the system knows the mark, and emptied again
|
||||
/// [PlatformSecretClipboard.keptFor] later if it still holds it.
|
||||
abstract interface class SecretClipboard() {
|
||||
/// Puts [text] on the clipboard, for a while.
|
||||
Future<void> copy(String text);
|
||||
}
|
||||
|
||||
/// [SecretClipboard] through the system: on Android through each app's
|
||||
/// `SensitiveClipboard.kt` (`app.halkyon/clipboard`), which marks the copy
|
||||
/// with `ClipDescription.EXTRA_IS_SENSITIVE`, so Gboard and the system show
|
||||
/// no preview and keep it out of their histories; elsewhere through
|
||||
/// Flutter's clipboard, which has no such mark (Windows' and KDE's histories
|
||||
/// may keep it, hence the short while). Emptied after [keptFor] if the
|
||||
/// clipboard still holds [copy]'s text; Android lets an app read the
|
||||
/// clipboard only while in front, so one sent away keeps the copy until
|
||||
/// replaced.
|
||||
class const PlatformSecretClipboard({
|
||||
final bool? marksSensitive,
|
||||
final Duration keptFor = const Duration(seconds: 60),
|
||||
}) implements SecretClipboard {
|
||||
/// The channel each app's Android activity answers.
|
||||
static const channel = MethodChannel('app.halkyon/clipboard');
|
||||
|
||||
bool get _marks =>
|
||||
marksSensitive ?? defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
@override
|
||||
Future<void> copy(String text) async {
|
||||
if (!_marks || !await _ask('copySensitive', text)) {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
}
|
||||
Timer(keptFor, () => unawaited(_emptyIfHolding(text)));
|
||||
}
|
||||
|
||||
Future<void> _emptyIfHolding(String text) async {
|
||||
if (_marks && await _ask('clearIf', text)) {
|
||||
return;
|
||||
}
|
||||
final now = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (now?.text == text) {
|
||||
await Clipboard.setData(const ClipboardData(text: ''));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the activity answered [method].
|
||||
Future<bool> _ask(String method, String text) async {
|
||||
try {
|
||||
await channel.invokeMethod<void>(method, text);
|
||||
return true;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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