NWC secret in secure storage

This commit is contained in:
franzap
2025-12-12 16:51:37 -03:00
parent 1551463b68
commit e5321794f6
9 changed files with 240 additions and 168 deletions
-6
View File
@@ -32,9 +32,6 @@ class ProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final isSignedIn = signedInPubkey != null;
return Scaffold(
body: ListView(
children: [
@@ -48,18 +45,15 @@ class ProfileScreen extends ConsumerWidget {
const SizedBox(height: 24),
// Settings Heading
if (isSignedIn)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text('Settings', style: context.textTheme.headlineSmall),
),
if (isSignedIn) ...[
const SizedBox(height: 16),
// Lightning Wallet Section
const NWCConnectionCard(),
],
const SizedBox(height: 24),
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Service for securely storing sensitive data (NWC connection strings)
/// using platform-native secure storage (Keychain on iOS, KeyStore on Android).
///
/// This does NOT require user authentication - data is encrypted at rest
/// by the platform's secure storage mechanism.
class SecureStorageService {
SecureStorageService();
static const _storage = FlutterSecureStorage();
static const _nwcKey = 'nwc_connection_string';
/// Get the stored NWC connection string
Future<String?> getNWCString() async {
final value = await _storage.read(key: _nwcKey);
return (value?.isNotEmpty == true) ? value : null;
}
/// Store an NWC connection string
Future<void> setNWCString(String connectionString) async {
await _storage.write(key: _nwcKey, value: connectionString);
}
/// Clear the stored NWC connection string
Future<void> clearNWCString() async {
await _storage.delete(key: _nwcKey);
}
/// Check if an NWC connection string is stored
Future<bool> hasNWCString() async {
final value = await _storage.read(key: _nwcKey);
return value?.isNotEmpty == true;
}
}
final secureStorageServiceProvider = Provider<SecureStorageService>(
(ref) => SecureStorageService(),
);
+38 -71
View File
@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/secure_storage_service.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart';
/// NWC connection status states
enum NWCStatus { notSignedIn, checking, connected, disconnected, error }
enum NWCStatus { checking, connected, disconnected, error }
/// Card showing NWC connection status and management
class NWCConnectionCard extends HookConsumerWidget {
@@ -16,38 +16,18 @@ class NWCConnectionCard extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final signer = ref.watch(Signer.activeSignerProvider);
final pubkey = ref.watch(Signer.activePubkeyProvider);
final isSignedIn = pubkey != null;
final secureStorage = ref.watch(secureStorageServiceProvider);
final nwcStatus = useState<NWCStatus>(NWCStatus.checking);
// Reactive NWC state via CustomData keyed by Signer.kNwcConnectionString
final nwcState = pubkey == null
? null
: ref.watch(
query<CustomData>(
authors: {pubkey},
tags: {
'#d': {Signer.kNwcConnectionString},
},
limit: 1,
source: const LocalSource(),
subscriptionPrefix: 'nwc-data',
),
);
final NWCStatus nwcStatus = () {
if (signer == null || pubkey == null) return NWCStatus.notSignedIn;
final state = nwcState;
if (state == null) return NWCStatus.checking;
return switch (state) {
StorageLoading() => NWCStatus.checking,
StorageError() => NWCStatus.error,
StorageData(:final models) =>
(models.isNotEmpty && models.first.content.trim().isNotEmpty)
? NWCStatus.connected
: NWCStatus.disconnected,
};
}();
// Check NWC status on mount
useEffect(() {
secureStorage.hasNWCString().then((hasNwc) {
nwcStatus.value = hasNwc ? NWCStatus.connected : NWCStatus.disconnected;
}).catchError((_) {
nwcStatus.value = NWCStatus.error;
});
return null;
}, []);
return Card(
child: Padding(
@@ -73,29 +53,14 @@ class NWCConnectionCard extends HookConsumerWidget {
const SizedBox(height: 12),
Text(
isSignedIn
? 'Use Nostr Wallet Connect (NWC) to zap the developers.'
: 'Connect a Lightning wallet to send zaps. Sign in required.',
'Use Nostr Wallet Connect (NWC) to zap the developers.',
style: context.textTheme.bodySmall,
),
const SizedBox(height: 12),
// Connection Status
switch (nwcStatus) {
NWCStatus.notSignedIn => Row(
children: [
Icon(
Icons.warning,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
size: 16,
),
const SizedBox(width: 8),
const Expanded(child: Text('Sign in to connect wallet')),
],
),
switch (nwcStatus.value) {
NWCStatus.checking => Row(
children: [
SizedBox(
@@ -155,14 +120,11 @@ class NWCConnectionCard extends HookConsumerWidget {
const SizedBox(height: 16),
// Action Buttons
if (isSignedIn)
Row(
children: [
Expanded(
child: TextButton.icon(
onPressed: nwcStatus != NWCStatus.notSignedIn
? () => _showNWCDialog(context, ref, signer)
: null,
onPressed: () => _showNWCDialog(context, ref, nwcStatus),
icon: Icon(
Icons.flash_on,
size: 18,
@@ -171,7 +133,7 @@ class NWCConnectionCard extends HookConsumerWidget {
: Theme.of(context).colorScheme.primary,
),
label: Text(
nwcStatus == NWCStatus.connected
nwcStatus.value == NWCStatus.connected
? 'Update Connection'
: 'Connect Wallet',
),
@@ -185,11 +147,11 @@ class NWCConnectionCard extends HookConsumerWidget {
),
),
),
if (nwcStatus == NWCStatus.connected) ...[
if (nwcStatus.value == NWCStatus.connected) ...[
const SizedBox(width: 8),
FilledButton(
onPressed: () =>
_removeNWCConnection(context, ref, signer),
_removeNWCConnection(context, ref, nwcStatus),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Colors.white,
@@ -210,22 +172,25 @@ class NWCConnectionCard extends HookConsumerWidget {
);
}
void _showNWCDialog(BuildContext context, WidgetRef ref, Signer? signer) {
if (signer == null) return;
showDialog(
void _showNWCDialog(
BuildContext context,
WidgetRef ref,
ValueNotifier<NWCStatus> nwcStatus,
) async {
final connected = await showDialog<bool>(
context: context,
builder: (context) => NWCConnectionDialog(signer: signer),
builder: (context) => NWCConnectionDialog(ref: ref),
);
if (connected == true) {
nwcStatus.value = NWCStatus.connected;
}
}
void _removeNWCConnection(
BuildContext context,
WidgetRef ref,
Signer? signer,
ValueNotifier<NWCStatus> nwcStatus,
) async {
if (signer == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
@@ -253,7 +218,9 @@ class NWCConnectionCard extends HookConsumerWidget {
if (confirmed == true) {
try {
await signer.setNWCString('');
final secureStorage = ref.read(secureStorageServiceProvider);
await secureStorage.clearNWCString();
nwcStatus.value = NWCStatus.disconnected;
if (context.mounted) {
context.showInfo('Wallet connection removed');
}
@@ -268,9 +235,9 @@ class NWCConnectionCard extends HookConsumerWidget {
/// Dialog for connecting/updating NWC connection
class NWCConnectionDialog extends HookWidget {
final Signer signer;
final WidgetRef ref;
const NWCConnectionDialog({super.key, required this.signer});
const NWCConnectionDialog({super.key, required this.ref});
@override
Widget build(BuildContext context) {
@@ -367,10 +334,11 @@ class NWCConnectionDialog extends HookWidget {
isLoading.value = true;
try {
await signer.setNWCString(nwcString.trim());
final secureStorage = ref.read(secureStorageServiceProvider);
await secureStorage.setNWCString(nwcString.trim());
if (context.mounted) {
Navigator.pop(context);
Navigator.pop(context, true);
context.showInfo('⚡ Lightning wallet connected successfully!');
}
} catch (e) {
@@ -382,4 +350,3 @@ class NWCConnectionDialog extends HookWidget {
}
}
}
+53 -40
View File
@@ -8,6 +8,7 @@ import 'package:models/models.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/secure_storage_service.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/common/base_dialog.dart';
import 'package:zapstore/widgets/common/profile_avatar.dart';
@@ -217,21 +218,15 @@ class ZapAmountDialog extends HookConsumerWidget {
],
);
final pubkey = ref.watch(Signer.activePubkeyProvider);
final signer = ref.watch(Signer.activeSignerProvider);
final secureStorage = ref.watch(secureStorageServiceProvider);
final hasWalletConnection = useState<bool>(false);
useEffect(() {
if (signer == null) {
hasWalletConnection.value = false;
return null;
}
signer
.getNWCString()
.then(
(value) => hasWalletConnection.value = (value?.isNotEmpty == true),
)
secureStorage
.hasNWCString()
.then((hasNwc) => hasWalletConnection.value = hasNwc)
.catchError((_) => hasWalletConnection.value = false);
return null;
}, [signer]);
}, []);
return BaseDialog(
titleIcon: const Text('⚡️'),
@@ -363,7 +358,7 @@ class ZapAmountDialog extends HookConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'Tap to sign in. You can zap anonymously or sign in for attribution.',
'Zapping anonymously. Tap to sign in.',
style: Theme.of(context).textTheme.bodyMedium,
),
),
@@ -374,14 +369,12 @@ class ZapAmountDialog extends HookConsumerWidget {
const SizedBox(height: 12),
],
if (pubkey != null && !hasWalletConnection.value) ...[
if (!hasWalletConnection.value) ...[
InkWell(
onTap: signer == null
? null
: () async {
onTap: () async {
final connected = await showBaseDialog<bool>(
context: context,
dialog: NWCConnectionDialogInline(signer: signer),
dialog: const NWCConnectionDialogInline(),
);
if (connected == true) {
hasWalletConnection.value = true;
@@ -478,6 +471,8 @@ class ZapAmountDialog extends HookConsumerWidget {
onPressed: selectedAmount.value > 0
? () async {
try {
final navigator = Navigator.of(context);
// Prepare signer (anonymous if needed)
var signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
@@ -488,8 +483,8 @@ class ZapAmountDialog extends HookConsumerWidget {
await signer.signIn(registerSigner: false);
}
// Read NWC state (may be empty)
final nwcString = await signer.getNWCString();
// Read NWC from secure storage
final nwcString = await secureStorage.getNWCString();
// Build zap request
final latestMetadata = app.latestFileMetadata;
@@ -516,29 +511,33 @@ class ZapAmountDialog extends HookConsumerWidget {
final signedZapRequest = await zapRequest.signWith(signer);
if (nwcString != null && nwcString.isNotEmpty) {
if (context.mounted) Navigator.of(context).pop(true);
// Continue payment in background (no await)
final amount = selectedAmount.value;
// Capture ScaffoldMessenger before popping (survives navigation)
final messenger = ScaffoldMessenger.maybeOf(context);
// Optimistic UX: show success immediately, pop, fire payment
context.showInfo('⚡ Zap sent! $amount sats');
navigator.pop(true);
// Fire payment in background - errors shown via ScaffoldMessenger
// ignore: unawaited_futures
signedZapRequest
.pay()
.then((_) {
if (context.mounted) {
context.showInfo(
'⚡ Zap successful! ${selectedAmount.value} sats sent',
_executeZapPayment(signedZapRequest, nwcString, ref.ref)
.catchError((Object e, StackTrace st) {
debugPrint('Zap payment failed: $e\n$st');
messenger?.showSnackBar(
SnackBar(
content: Text('Zap failed: $e'),
backgroundColor: Colors.red,
),
);
}
})
.catchError((e) {
if (context.mounted) {
context.showError('Zap failed: $e');
}
return Future<PayInvoiceResult>.error(e, st);
});
} else {
final invoice = await signedZapRequest.getInvoice();
if (context.mounted) Navigator.of(context).pop(true);
await Clipboard.setData(ClipboardData(text: invoice));
if (context.mounted) {
context.showInfo('Invoice copied to clipboard');
navigator.pop(true);
}
}
} catch (e) {
@@ -579,6 +578,21 @@ class ZapAmountDialog extends HookConsumerWidget {
}
}
/// Execute zap payment using NWC connection string from secure storage
Future<PayInvoiceResult> _executeZapPayment(
ZapRequest signedZapRequest,
String nwcString,
Ref ref,
) async {
final lightningInvoice = await signedZapRequest.getInvoice();
final command = PayInvoiceCommand(invoice: lightningInvoice);
return await command.execute(
connectionUri: nwcString,
ref: ref,
timeout: const Duration(seconds: 30),
);
}
/// Dialog for entering a custom zap amount
class CustomAmountDialog extends HookWidget {
const CustomAmountDialog({super.key});
@@ -620,15 +634,14 @@ class CustomAmountDialog extends HookWidget {
}
/// Inline NWC connection dialog used in zap flow
class NWCConnectionDialogInline extends HookWidget {
final Signer signer;
const NWCConnectionDialogInline({super.key, required this.signer});
class NWCConnectionDialogInline extends HookConsumerWidget {
const NWCConnectionDialogInline({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final isLoading = useState(false);
final secureStorage = ref.watch(secureStorageServiceProvider);
return BaseDialog(
titleIcon: const Text('⚡️'),
@@ -693,7 +706,7 @@ class NWCConnectionDialogInline extends HookWidget {
}
isLoading.value = true;
try {
await signer.setNWCString(nwcString.trim());
await secureStorage.setNWCString(nwcString.trim());
if (context.mounted) {
Navigator.pop(context, true);
context.showInfo(
@@ -6,11 +6,15 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <gtk/gtk_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
gtk
sqlite3_flutter_libs
url_launcher_linux
@@ -7,6 +7,7 @@ import Foundation
import app_links
import connectivity_plus
import flutter_secure_storage_darwin
import path_provider_foundation
import share_plus
import shared_preferences_foundation
@@ -17,6 +18,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+48
View File
@@ -399,6 +399,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_svg:
dependency: transitive
description:
+1
View File
@@ -53,6 +53,7 @@ dependencies:
background_downloader: ^9.2.6
connectivity_plus: ^7.0.0
app_links: ^6.4.1
flutter_secure_storage: ^10.0.0
dependency_overrides:
models: