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
20 changed files with 470 additions and 36 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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 '
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue