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