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

View file

@ -20,6 +20,7 @@ class MainActivity : FlutterActivity() {
val messenger = flutterEngine.dartExecutor.binaryMessenger val messenger = flutterEngine.dartExecutor.binaryMessenger
PomodoroService.appBack() PomodoroService.appBack()
ScreenGuard.attach(this, messenger) ScreenGuard.attach(this, messenger)
SensitiveClipboard.attach(this, messenger)
// A question searched with the device's own web search (its default // A question searched with the device's own web search (its default
// browser or search app answers ACTION_WEB_SEARCH); false where none does, // browser or search app answers ACTION_WEB_SEARCH); false where none does,
// and the app opens a search engine's address instead. // and the app opens a search engine's address instead.

View file

@ -0,0 +1,46 @@
package app.halkyon.learn
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* Copies an authenticator's key for halkyon_app's PlatformSecretClipboard,
* marked sensitive (ClipDescription.EXTRA_IS_SENSITIVE, read by the system
* from Android 13 on and by Gboard before it), so it shows no preview and
* stays out of clipboard histories; and empties the clipboard again if it
* still holds the key, which Android lets an app read only while in front.
*/
object SensitiveClipboard {
private const val IS_SENSITIVE = "android.content.extra.IS_SENSITIVE"
fun attach(context: Context, messenger: BinaryMessenger) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
MethodChannel(messenger, "app.halkyon/clipboard").setMethodCallHandler { call, result ->
when (call.method) {
"copySensitive" -> {
val clip = ClipData.newPlainText("", call.arguments as String)
clip.description.extras = PersistableBundle().apply { putBoolean(IS_SENSITIVE, true) }
clipboard.setPrimaryClip(clip)
result.success(null)
}
"clearIf" -> {
val held = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
if (held?.toString() == call.arguments as String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
clipboard.clearPrimaryClip()
} else {
clipboard.setPrimaryClip(ClipData.newPlainText("", ""))
}
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
}

View file

@ -8,5 +8,6 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// Secrets halkyon_app shows kept out of screenshots and recents. // Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger) ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
SensitiveClipboard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
} }
} }

View file

@ -0,0 +1,46 @@
package app.halkyon.meditate
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* Copies an authenticator's key for halkyon_app's PlatformSecretClipboard,
* marked sensitive (ClipDescription.EXTRA_IS_SENSITIVE, read by the system
* from Android 13 on and by Gboard before it), so it shows no preview and
* stays out of clipboard histories; and empties the clipboard again if it
* still holds the key, which Android lets an app read only while in front.
*/
object SensitiveClipboard {
private const val IS_SENSITIVE = "android.content.extra.IS_SENSITIVE"
fun attach(context: Context, messenger: BinaryMessenger) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
MethodChannel(messenger, "app.halkyon/clipboard").setMethodCallHandler { call, result ->
when (call.method) {
"copySensitive" -> {
val clip = ClipData.newPlainText("", call.arguments as String)
clip.description.extras = PersistableBundle().apply { putBoolean(IS_SENSITIVE, true) }
clipboard.setPrimaryClip(clip)
result.success(null)
}
"clearIf" -> {
val held = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
if (held?.toString() == call.arguments as String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
clipboard.clearPrimaryClip()
} else {
clipboard.setPrimaryClip(ClipData.newPlainText("", ""))
}
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
}

View file

@ -8,5 +8,6 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// Secrets halkyon_app shows kept out of screenshots and recents. // Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger) ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
SensitiveClipboard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
} }
} }

View file

@ -0,0 +1,46 @@
package app.halkyon.todo
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* Copies an authenticator's key for halkyon_app's PlatformSecretClipboard,
* marked sensitive (ClipDescription.EXTRA_IS_SENSITIVE, read by the system
* from Android 13 on and by Gboard before it), so it shows no preview and
* stays out of clipboard histories; and empties the clipboard again if it
* still holds the key, which Android lets an app read only while in front.
*/
object SensitiveClipboard {
private const val IS_SENSITIVE = "android.content.extra.IS_SENSITIVE"
fun attach(context: Context, messenger: BinaryMessenger) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
MethodChannel(messenger, "app.halkyon/clipboard").setMethodCallHandler { call, result ->
when (call.method) {
"copySensitive" -> {
val clip = ClipData.newPlainText("", call.arguments as String)
clip.description.extras = PersistableBundle().apply { putBoolean(IS_SENSITIVE, true) }
clipboard.setPrimaryClip(clip)
result.success(null)
}
"clearIf" -> {
val held = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
if (held?.toString() == call.arguments as String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
clipboard.clearPrimaryClip()
} else {
clipboard.setPrimaryClip(ClipData.newPlainText("", ""))
}
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
}

View file

@ -69,6 +69,7 @@ class MainActivity : FlutterActivity() {
val messenger = flutterEngine.dartExecutor.binaryMessenger val messenger = flutterEngine.dartExecutor.binaryMessenger
// Secrets halkyon_app shows kept out of screenshots and recents. // Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, messenger) ScreenGuard.attach(this, messenger)
SensitiveClipboard.attach(this, messenger)
// The recording's side of the system (RecorderService). // The recording's side of the system (RecorderService).
MethodChannel(messenger, "app.halkyon.train/recorder").setMethodCallHandler { call, result -> MethodChannel(messenger, "app.halkyon.train/recorder").setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {

View file

@ -0,0 +1,46 @@
package app.halkyon.train
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* Copies an authenticator's key for halkyon_app's PlatformSecretClipboard,
* marked sensitive (ClipDescription.EXTRA_IS_SENSITIVE, read by the system
* from Android 13 on and by Gboard before it), so it shows no preview and
* stays out of clipboard histories; and empties the clipboard again if it
* still holds the key, which Android lets an app read only while in front.
*/
object SensitiveClipboard {
private const val IS_SENSITIVE = "android.content.extra.IS_SENSITIVE"
fun attach(context: Context, messenger: BinaryMessenger) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
MethodChannel(messenger, "app.halkyon/clipboard").setMethodCallHandler { call, result ->
when (call.method) {
"copySensitive" -> {
val clip = ClipData.newPlainText("", call.arguments as String)
clip.description.extras = PersistableBundle().apply { putBoolean(IS_SENSITIVE, true) }
clipboard.setPrimaryClip(clip)
result.success(null)
}
"clearIf" -> {
val held = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
if (held?.toString() == call.arguments as String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
clipboard.clearPrimaryClip()
} else {
clipboard.setPrimaryClip(ClipData.newPlainText("", ""))
}
}
result.success(null)
}
else -> result.notImplemented()
}
}
}
}

View file

@ -584,6 +584,17 @@ keychain on 21 Oct; the 30-day grace for GitHub-only accounts was proposed and k
- **The clipboard (medium):** copies reached clipboard histories and cloud clipboards, - **The clipboard (medium):** copies reached clipboard histories and cloud clipboards,
and Android often couldn't empty it again. Nothing secret is copied any more: codes and Android often couldn't empty it again. Nothing secret is copied any more: codes
are saved, like the phrase; an authenticator's key is scanned, opened or typed. are saved, like the phrase; an authenticator's key is scanned, opened or typed.
*Amended 3 Nov 2026 (the developer):* a phone without a camera whose authenticator
doesn't open `otpauth:` had no way but typing, so the key alone is copied again
(`PlatformSecretClipboard`): on Android through each app's `SensitiveClipboard.kt`,
marked `EXTRA_IS_SENSITIVE` (no preview, kept out of Gboard's and the system's
histories); elsewhere through Flutter's clipboard, which has no mark (Windows' and
KDE's histories may keep it). Either way emptied 60 seconds later if it still holds
the key, which Android allows only while the app is in front. Alternatives weighed:
phones only (the desktop stays type-only), and keeping it uncopyable with the key in
groups of four. The phrase and the recovery codes stay never copied. The code's
field takes six digits alone, a pasted space dropped, without a counter (it read
"/7", taking the space as a seventh).
- **Saving (medium-low):** a saved file was made, then set to 0600, and written into - **Saving (medium-low):** a saved file was made, then set to 0600, and written into
whatever the path held. Now a new file beside it, 0600 as it is made (`O_EXCL`, whatever the path held. Now a new file beside it, 0600 as it is made (`O_EXCL`,
`O_NOFOLLOW`), written through its descriptor and renamed over the path; a symbolic `O_NOFOLLOW`), written through its descriptor and renamed over the path; a symbolic

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_sign_in_sheet.dart';
export 'src/password/widget/password_texts.dart'; export 'src/password/widget/password_texts.dart';
export 'src/password/widget/password_widgets.dart'; export 'src/password/widget/password_widgets.dart';
export 'src/password/widget/secret_clipboard.dart';
export 'src/password/widget/secret_screen.dart'; export 'src/password/widget/secret_screen.dart';
export 'src/person/person_folders.dart'; export 'src/person/person_folders.dart';
export 'src/privacy/content_cipher.dart'; export 'src/privacy/content_cipher.dart';

View file

@ -126,6 +126,7 @@
"passwordAuthenticatorTitle": "Authenticator einrichten", "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.", "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", "passwordAuthenticatorKey": "Schlüssel",
"passwordAuthenticatorKeyCopied": "Schlüssel kopiert. In einer Minute wird er aus der Zwischenablage entfernt.",
"passwordAuthenticatorOpen": "In deiner Authenticator-App öffnen", "passwordAuthenticatorOpen": "In deiner Authenticator-App öffnen",
"passwordAuthenticatorNoApp": "Keine App auf diesem Gerät öffnet ihn. Scanne den Code oder tippe den Schlüssel ein.", "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.", "passwordAuthenticatorCodeIntro": "Gib dann den Code ein, den sie zeigt.",

View file

@ -413,6 +413,10 @@
"passwordAuthenticatorTitle": "Set up an authenticator", "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.", "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", "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", "passwordAuthenticatorOpen": "Open in your authenticator app",
"passwordAuthenticatorNoApp": "No app on this device opens it. Scan the code or type in the key.", "passwordAuthenticatorNoApp": "No app on this device opens it. Scan the code or type in the key.",
"passwordAuthenticatorCodeIntro": "Then enter the code it shows.", "passwordAuthenticatorCodeIntro": "Then enter the code it shows.",

View file

@ -859,6 +859,12 @@ abstract class HalkyonAppLocalizations {
/// **'Key'** /// **'Key'**
String get passwordAuthenticatorKey; 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. /// No description provided for @passwordAuthenticatorOpen.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -485,6 +485,10 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override @override
String get passwordAuthenticatorKey => 'Schlüssel'; String get passwordAuthenticatorKey => 'Schlüssel';
@override
String get passwordAuthenticatorKeyCopied =>
'Schlüssel kopiert. In einer Minute wird er aus der Zwischenablage entfernt.';
@override @override
String get passwordAuthenticatorOpen => 'In deiner Authenticator-App öffnen'; String get passwordAuthenticatorOpen => 'In deiner Authenticator-App öffnen';

View file

@ -475,6 +475,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override @override
String get passwordAuthenticatorKey => 'Key'; String get passwordAuthenticatorKey => 'Key';
@override
String get passwordAuthenticatorKeyCopied =>
'Key copied. It\'s taken off the clipboard again in a minute.';
@override @override
String get passwordAuthenticatorOpen => 'Open in your authenticator app'; String get passwordAuthenticatorOpen => 'Open in your authenticator app';

View file

@ -24,9 +24,12 @@ final class const AuthenticatorSetup({
bool get isAuthenticatorUri => bool get isAuthenticatorUri =>
Uri.tryParse(uri)?.isScheme('otpauth') ?? false; 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. /// The secret the URI holds, Base32, in groups of four to type by hand.
String get secret { String get secret {
final raw = Uri.tryParse(uri)?.queryParameters['secret'] ?? ''; final raw = rawSecret;
return [ return [
for (var i = 0; i < raw.length; i += 4) for (var i = 0; i < raw.length; i += 4)
raw.substring(i, i + 4 > raw.length ? 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/app/app_scope.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.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/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_app/src/password/widget/secret_screen.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength; import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength;
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -163,14 +164,16 @@ class const CodeField({
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
textInputAction: textInputAction, textInputAction: textInputAction,
onSubmitted: onSubmitted, onSubmitted: onSubmitted,
// Six digits, a space perhaps between them; eight characters, a dash // Six digits, a space pasted between them dropped (no counter: six
// or space perhaps between them. // is what every authenticator shows); eight characters, a dash or
maxLength: digits ? 7 : 9, // space perhaps between them.
buildCounter: nearLimitCounter, maxLength: digits ? 6 : 9,
buildCounter: digits ? _noCounter : nearLimitCounter,
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow( if (digits)
digits ? RegExp('[0-9 ]') : RegExp(r'[0-9A-Za-z\- ]'), FilteringTextInputFormatter.digitsOnly
), else
FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Za-z\- ]')),
], ],
decoration: InputDecoration( decoration: InputDecoration(
labelText: 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. /// A sheet's step in progress: the spinner, and what is happening.
class const SheetWorking({final String? message, super.key}) class const SheetWorking({final String? message, super.key})
extends StatelessWidget { extends StatelessWidget {
@ -742,6 +752,7 @@ class const AuthenticatorSetupView({
final String? existingErrorText, final String? existingErrorText,
final bool enabled = true, final bool enabled = true,
final VoidCallback? onDone, final VoidCallback? onDone,
final SecretClipboard clipboard = const PlatformSecretClipboard(),
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
@ -766,14 +777,33 @@ class const AuthenticatorSetupView({
style: theme.textTheme.labelLarge, style: theme.textTheme.labelLarge,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Typed from the screen, never copied or selectable // Not selectable, as the system's own copy would stay; copied
// ([KeepTextButtons]): the QR code and Open in authenticator app // through [clipboard], marked sensitive and emptied again, for a
// hand it over otherwise. // device without a camera whose authenticator doesn't open it.
Text( Row(
setup.secret, children: [
style: theme.textTheme.bodyLarge?.copyWith( Expanded(
fontFeatures: const [FontFeature.tabularFigures()], 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) ...[ if (phone) ...[
const SizedBox(height: 8), 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 // Nothing secret is copied (the reading of the fixes): no app can mark
// a copy as sensitive through Flutter, so clipboard histories (Windows, // a copy as sensitive through Flutter, so clipboard histories (Windows,
// KDE, Gboard, Samsung's) and cloud clipboards would keep it, and on // 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 { testWidgets('the recovery phrase is saved, never copied', (tester) async {
mockClipboard(tester); mockClipboard(tester);
@ -82,26 +83,129 @@ void main() {
expect(clipboard, isNull); expect(clipboard, isNull);
}); });
testWidgets("an authenticator's key is scanned, opened or typed from the " group("an authenticator's key, for a device without a camera, is copied "
'screen, never copied', (tester) async { 'and emptied again a minute later; never selectable (decided 3 Nov '
mockClipboard(tester); '2026)', () {
final code = TextEditingController(); const setup = AuthenticatorSetup(
addTearDown(code.dispose); id: 'a',
await pumpView( uri: 'otpauth://totp/x:anna?secret=JBSWY3DPEHPK3PXP',
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); /// What each app's Android activity was asked
expect(find.text('Copy'), findsNothing); /// (`app.halkyon/clipboard`), and what it held.
expect(find.byType(SelectableText), findsNothing); late List<String> asked;
expect(clipboard, isNull); 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 ' 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); 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');
});
} }