fix(app): only an otpauth URI is shown as a QR code or opened

The instance sends the URI an authenticator is set up from; one that
isn't otpauth: (a web page, an Android intent) was drawn as a QR code and
handed to the system to open. Now it is neither; the key stays to type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 14:50:07 +02:00
commit 5270ce6775
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 64 additions and 3 deletions

View file

@ -18,6 +18,12 @@ final class const AuthenticatorSetup({
required final String id,
required final String uri,
}) with Equatable {
/// Whether [uri] is what an authenticator app opens (`otpauth:`): only
/// then is it shown as a QR code or handed to the system to open, as the
/// instance could send any address (a web page, an intent).
bool get isAuthenticatorUri =>
Uri.tryParse(uri)?.isScheme('otpauth') ?? false;
/// The secret the URI holds, Base32, in groups of four to type by hand.
String get secret {
final raw = Uri.tryParse(uri)?.queryParameters['secret'] ?? '';

View file

@ -550,13 +550,17 @@ class const AuthenticatorSetupView({
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final phone = Platform.isAndroid || Platform.isIOS;
// Only an otpauth URI is shown as a QR code or opened ([AuthenticatorSetup.isAuthenticatorUri]).
final opens = setup.isAuthenticatorUri;
final phone = opens && (Platform.isAndroid || Platform.isIOS);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SheetIntro(l10n.passwordAuthenticatorIntro),
Center(child: QrCodeView(data: setup.uri)),
const SizedBox(height: 12),
if (opens) ...[
Center(child: QrCodeView(data: setup.uri)),
const SizedBox(height: 12),
],
Text(l10n.passwordAuthenticatorKey, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
Row(

View file

@ -57,4 +57,55 @@ void main() {
);
expectNothingLearnt(tester, "the phrase's words");
});
test("only an otpauth URI is an authenticator's to open", () {
expect(
const AuthenticatorSetup(
id: 'a',
uri: 'otpauth://totp/x:anna?secret=JBSWY3DPEHPK3PXP',
).isAuthenticatorUri,
isTrue,
);
for (final uri in [
'https://evil.example/otpauth',
'intent://scan#Intent;scheme=otpauth;end',
'tel:+4930123',
'',
]) {
expect(
AuthenticatorSetup(id: 'a', uri: uri).isAuthenticatorUri,
isFalse,
reason: uri,
);
}
});
testWidgets('what an instance sends that is no otpauth URI is never shown '
'as a QR code', (tester) async {
final code = TextEditingController();
addTearDown(code.dispose);
await pump(
tester,
AuthenticatorSetupView(
setup: const AuthenticatorSetup(
id: 'a',
uri: 'https://evil.example/?secret=JBSWY3DPEHPK3PXP',
),
code: code,
),
);
expect(find.byType(QrCodeView), findsNothing);
await pump(
tester,
AuthenticatorSetupView(
setup: const AuthenticatorSetup(
id: 'a',
uri: 'otpauth://totp/x:anna?secret=JBSWY3DPEHPK3PXP',
),
code: code,
),
);
expect(find.byType(QrCodeView), findsOneWidget);
});
}