Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/icons/passphrase.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions lib/coins/bitcoin/creation/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ class BitcoinWalletCreation extends WalletCreation {
);

late StringFormElement passphrase = StringFormElement(
L.wallet_passphrase,
password: false,
L.seed_offset,
password: true,
validator: nonEmptyValidator(
L,
extra: (final input) => null,
Expand All @@ -49,8 +49,8 @@ class BitcoinWalletCreation extends WalletCreation {
);

late StringFormElement passphraseConfirm = StringFormElement(
L.wallet_passphrase,
password: false,
L.seed_offset_confirm,
password: true,
validator: nonEmptyValidator(
L,
extra: (final input) => input != passphrase.ctrl.text ? L.seed_passphrase_mismatch : null,
Expand Down
8 changes: 4 additions & 4 deletions lib/coins/litecoin/creation/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ class LitecoinWalletCreation extends WalletCreation {
);

late StringFormElement passphrase = StringFormElement(
L.wallet_passphrase,
password: false,
L.seed_offset,
password: true,
validator: nonEmptyValidator(
L,
extra: (final input) => null,
Expand All @@ -49,8 +49,8 @@ class LitecoinWalletCreation extends WalletCreation {
);

late StringFormElement passphraseConfirm = StringFormElement(
L.wallet_passphrase,
password: false,
L.seed_offset_confirm,
password: true,
validator: nonEmptyValidator(
L,
extra: (final input) => input != passphrase.ctrl.text ? L.seed_passphrase_mismatch : null,
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"copied": "Copied {what}",
"copy": "Copy",
"create_new_wallet": "Create new wallet",
"create_passphrase_warning_text": "Make sure this passphrase is saved. Without this passphrase you will not be able to restore your funds. It is recommended to save your passphrase in a different location from your seed for optimal security.",
"create_wallet": "Create Wallet",
"create_your_pin": "Create your PIN",
"creating_wallet": "Creating wallet",
Expand Down Expand Up @@ -96,6 +97,7 @@
"rename": "Rename",
"rename_wallet": "Rename wallet",
"restore_height": "Restore height",
"restore_passphrase_warning_text": "Only enter a passphrase if you have used one for thie wallet in the past. If you enter the wrong passphrase or have not used a passphrase before on this wallet, you won't see any of existing funds or history.",
"restore_wallet": "Restore wallet",
"restoring_wallet": "Restoring wallet",
"save": "Save",
Expand Down Expand Up @@ -162,6 +164,7 @@
"wallet_password_repeat": "Wallet password (repeat)",
"wallet_seed": "Wallet seed",
"wallets": "Wallets",
"warning": "Warning",
"warning_input_cannot_be_empty": "Input cannot be empty",
"warning_input_cannot_be_null": "Input cannot be null",
"warning_input_seed_length_invalid": "Incorrect number of words present in the seed",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_pl.arb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"confirming": "Potwierdzanie",
"copy": "Kopiuj",
"create_new_wallet": "Stwórz nowy portfel",
"create_passphrase_warning_text": "Upewnij się, że to hasło zostało zapisane. Bez tego hasła nie będziesz w stanie odzyskać swoich środków. Zaleca się przechowywanie hasła w innym miejscu niż fraza odzyskiwania, aby zapewnić optymalne bezpieczeństwo.",
"create_wallet": "Stwórz Portfel",
"creating_wallet": "Tworzenie portfela",
"cupcake_slogan": "Bezpieczeństwo - jeszcze łatwiejsze",
Expand All @@ -28,6 +29,7 @@
"primary_address_label": "Adres główny",
"receive": "Wpłać",
"restore_height": "Wysokość przywracania",
"restore_passphrase_warning_text": "Wprowadź hasło tylko wtedy, gdy było ono wcześniej używane w tym portfelu. Jeśli wprowadzisz nieprawidłowe hasło lub nie było ono wcześniej używane w tym portfelu, nie zobaczysz żadnych istniejących środków ani historii.",
"restore_wallet": "Przywróć portfel",
"save": "Zapisz",
"scan": "Skanuj",
Expand Down Expand Up @@ -55,6 +57,7 @@
"wallet_password_repeat": "Hasło portfela (ponownie)",
"wallet_seed": "Fraza odzyskiwania",
"wallets": "Portfele",
"warning": "Warning",
"warning_input_cannot_be_empty": "Pole nie może być puste",
"warning_input_cannot_be_null": "Pole nie może być puste",
"warning_must_be_a_number": "W pole należy wprowadzić liczbę",
Expand Down
4 changes: 3 additions & 1 deletion lib/view_model/create_wallet_view_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,9 @@ abstract class CreateWalletViewModelBase extends ViewModel with Store {
// verify that password match when confirming, ignore otherwise
if (walletPassword1 != walletPassword2 && walletPassword2.isNotEmpty) {
if (kDebugMode) {
throw Exception("${L.password_doesnt_match} /$walletPassword1/$walletPassword2/");
throw Exception(
"${L.password_doesnt_match} /$walletPassword1/$walletPassword2/",
);
}
throw Exception(L.password_doesnt_match);
}
Expand Down
150 changes: 148 additions & 2 deletions lib/views/create_wallet.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import 'dart:ui';

import 'package:cupcake/utils/text_span_markdown.dart';
import 'package:cupcake/utils/types.dart';
import 'package:cupcake/gen/assets.gen.dart';
import 'package:cupcake/view_model/create_wallet_view_model.dart';
import 'package:cupcake/utils/alerts/basic.dart';
import 'package:cupcake/utils/form/string_form_element.dart';
import 'package:cupcake/view_model/form_builder_view_model.dart';
import 'package:cupcake/views/abstract.dart';
import 'package:cupcake/views/widgets/buttons/long_primary.dart';
Expand Down Expand Up @@ -153,7 +157,7 @@ class CreateWallet extends AbstractView {
}
}
final form = FormBuilder(
showExtra: viewModel.showExtra,
showExtra: false,
viewModel: viewModel.formBuilderViewModelList[viewModel.formIndex] as FormBuilderViewModel,
);
return Column(
Expand Down Expand Up @@ -241,12 +245,154 @@ class CreateWallet extends AbstractView {
LongPrimaryButton(
text: L.continue_,
icon: null,
onPressed: viewModel.createWallet,
onPressed: () async {
if (viewModel.showExtra) {
await _showBottomSheet(context);
} else {
await viewModel.createWallet();
}
},
),
],
);
}

String? _extraFieldsError() {
final elements = viewModel.formBuilderViewModelList[viewModel.formIndex].formElements
.where((final e) => e.isExtra)
.whereType<StringFormElement>();
for (final e in elements) {
final error = e.validator(e.ctrl.text);
if (error != null) return error;
}
return null;
}

Future<void> _showBottomSheet(final BuildContext context) async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security audit finding — MEDIUM

Passphrase bottom-sheet Confirm is unguarded and the sheet is never dismissed — double-fire (or a stale re-tap) silently overwrites the wallet's encrypted .keys backup with a different mnemonic

Severity medium
Confidence high
Category other
CWE CWE-362, CWE-799
Audited f50f4c48 (run eceaea44e9f0)

Affected

  • lib/views/create_wallet.dart L271-L288
  • lib/views/create_wallet.dart L361-L376

What

Introduced by the diff. The new passphrase sheet's Confirm runs await viewModel.createWallet() with no Navigator.pop on success and no in-flight guard (LongPrimaryButton has no busy/disabled state). Two rapid taps call createWallet() twice concurrently: each _createWallet() generates a fresh Mnemonic.create(WordCount.words12), and both write the SAME *.keys file via File(keys).writeAsBytesSync (last-writer-wins). The two executions then each push NewWalletInfoScreen/WalletHome on the root navigator, so the user is shown the first seed on the info pages while the on-disk wallet holds the second mnemonic — after restart the wallet derives keys the user never backed up. Because the modal is isDismissible: false and never popped before createWallet() pushes the next route (via Navigator.of(c!)), the sheet with the filled, masked passphrase and a live Confirm stays mounted underneath the wallet terminal screens (NewWalletInfoScreen/WalletHome are canPop: false, so the buried sheet is not directly reachable on success but is reachable whenever the create flow errors/retries). While the unguarded double-submit class also existed on the base's Continue button, the new sheet is a new creation entry point with the same flaw and the modal-dismissal regression is new.

Remediation

In the Confirm handler, guard against concurrent execution (e.g. a Future<bool> _creating in-flight flag or disable the button while createWallet() runs) and Navigator.pop() the sheet before/after a successful createWallet(). Prefer pushing the post-creation screens only after the sheet route is removed (pop the modal, then navigate).

Posted from the Cake security-audit bot. Use the ❌ False positive button in Slack if this is not a real issue.

await showModalBottomSheet(
context: context,
isDismissible: false,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (final context) => BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
decoration: const BoxDecoration(
color: Color(0xFF273765),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: _buildBottomSheet(context),
),
),
);
}

Widget _buildBottomSheet(final BuildContext context) {
return Builder(
builder: (final context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
decoration: const BoxDecoration(
color: Color(0xFF273765),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withAlpha(76),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
spacing: 16,
children: [
Text(L.add_passphrase, style: Theme.of(context).textTheme.titleLarge),
Assets.icons.passphrase.image(width: 200),
Text.rich(
TextSpan(
children: [
TextSpan(
text: '${L.warning.toUpperCase()}: ',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.errorContainer,
decoration: TextDecoration.none,
),
),
TextSpan(
text: viewModel.createMethod == CreateMethod.restore
? L.restore_passphrase_warning_text
: L.create_passphrase_warning_text,
),
],
),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
decoration: TextDecoration.none,
),
),
FormBuilder(
showExtra: true,
extraOnly: true,
viewModel: viewModel.formBuilderViewModelList[viewModel.formIndex]
as FormBuilderViewModel,
),
Row(
children: [
Expanded(
child: LongSecondaryButton(
T,
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.only(right: 10, left: 12),
text: "Cancel",
),
),
Expanded(
child: LongPrimaryButton(
onPressed: () async {
final error = _extraFieldsError();
if (error != null) {
await showAlert(
context: context,
title: L.warning,
body: [error],
);
return;
}
await viewModel.createWallet();
},
padding: EdgeInsets.only(left: 10),
text: "Confirm",
),
),
],
),
],
),
),
const SizedBox(height: 20),
],
),
),
),
);
},
);
}

@override
Widget build(final BuildContext context) {
viewModel.register(context);
Expand Down
13 changes: 11 additions & 2 deletions lib/views/widgets/form_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@ import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';

class FormBuilder extends StatefulWidget {
const FormBuilder({super.key, required this.viewModel, required this.showExtra});
const FormBuilder({
super.key,
required this.viewModel,
required this.showExtra,
this.extraOnly = false,
});

final FormBuilderViewModel viewModel;
final bool showExtra;

final bool extraOnly;

@override
State<FormBuilder> createState() => _FormBuilderState();
}
Expand Down Expand Up @@ -176,7 +183,9 @@ class _FormBuilderState extends State<FormBuilder> {
_onLabelChange(null);
final List<Widget> children = [];
for (final e in widget.viewModel.formElements) {
if (e.isExtra && !widget.showExtra) {
if (widget.extraOnly) {
if (!e.isExtra) continue;
} else if (e.isExtra && !widget.showExtra) {
// If we return Container() some stuff happens on flutter render cache
// and it doesn't render properly.
continue;
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ flutter:
- assets/icons/secure_storage.webp
- assets/icons/tiny_cake_labs.svg
- assets/icons/cupcake_about.svg
- assets/icons/passphrase.png

fonts:
- family: Lato
Expand Down
Loading