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
11 changed files with 470 additions and 36 deletions

View file

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

View file

@ -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.",

View file

@ -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.",

View file

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

View file

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

View file

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

View file

@ -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),

View file

@ -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),

View file

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

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