diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 54a8b32..a3a39be 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + diff --git a/lib/core/repositories/share_key_repository.dart b/lib/core/repositories/share_key_repository.dart new file mode 100644 index 0000000..e0d239d --- /dev/null +++ b/lib/core/repositories/share_key_repository.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +import 'package:sharedinbox/core/services/share_encryption_service.dart'; + +/// Stores and retrieves ephemeral X25519 key pairs for secure account sharing. +abstract class ShareKeyRepository { + /// Generates a new key pair and persists it with a 20-minute expiry. + Future createKeyPair(); + + /// Returns the key pair whose ID matches [keyId], or null if not found / + /// expired. + Future findByKeyId(Uint8List keyId); +} diff --git a/lib/core/services/share_encryption_service.dart b/lib/core/services/share_encryption_service.dart new file mode 100644 index 0000000..2dc37eb --- /dev/null +++ b/lib/core/services/share_encryption_service.dart @@ -0,0 +1,295 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; + +const _pubKeyPrefix = 'sharedinbox.de:pubkey:v1:'; +const _encAccountsPrefix = 'sharedinbox.de:encrypted-accounts:v1:'; + +// ECIES wire sizes (bytes). +const _keyIdLen = 16; +const _pubKeyLen = 32; +const _nonceLen = 12; +const _macLen = 16; + +/// Describes a freshly generated key pair before it is written to the database. +class ShareKeyMaterial { + const ShareKeyMaterial({ + required this.keyId, + required this.publicKeyBytes, + required this.privateKeyBytes, + }); + + /// Random 16-byte identifier (hex-encoded when stored / included in QR). + final Uint8List keyId; + + /// X25519 public key, 32 bytes. + final Uint8List publicKeyBytes; + + /// X25519 private key, 32 bytes. + final Uint8List privateKeyBytes; +} + +/// An account + password pair, used in the plaintext payload before encryption. +class AccountPayload { + const AccountPayload({required this.accountJson, required this.password}); + + final Map accountJson; + final String password; +} + +/// Pure-Dart cryptographic helpers for the secure account-sharing flow. +/// +/// Protocol: +/// Receiver generates an X25519 key pair with 20-minute lifetime and shows +/// its public key as a QR code. The sender scans that QR, encrypts the +/// selected account(s) using ECIES (X25519-ECDH + HKDF-SHA256 + AES-256-GCM) +/// and shows the encrypted payload as a QR code. The receiver scans that QR, +/// looks up the private key by the embedded key-ID, and decrypts. +class ShareEncryptionService { + static final _x25519 = X25519(); + static final _aesGcm = AesGcm.with256bits(); + static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + static final _rng = Random.secure(); + + // ── Key generation ────────────────────────────────────────────────────────── + + static Future generateKeyPair() async { + final keyId = Uint8List(_keyIdLen); + for (var i = 0; i < _keyIdLen; i++) { + keyId[i] = _rng.nextInt(256); + } + + final keyPair = await _x25519.newKeyPair(); + final pub = await keyPair.extractPublicKey(); + final priv = await keyPair.extractPrivateKeyBytes(); + + return ShareKeyMaterial( + keyId: keyId, + publicKeyBytes: Uint8List.fromList(pub.bytes), + privateKeyBytes: Uint8List.fromList(priv), + ); + } + + // ── Public-key QR encoding / parsing ──────────────────────────────────────── + + /// Encodes the receiver's public key as a QR-code string. + /// + /// Format: `sharedinbox.de:pubkey:v1:` + static String encodePublicKeyQr(Uint8List keyId, Uint8List publicKeyBytes) { + assert(keyId.length == _keyIdLen); + assert(publicKeyBytes.length == _pubKeyLen); + final data = Uint8List(_keyIdLen + _pubKeyLen) + ..setAll(0, keyId) + ..setAll(_keyIdLen, publicKeyBytes); + return '$_pubKeyPrefix${base64.encode(data)}'; + } + + /// Parses a public-key QR string. Returns null if the format is invalid. + static ({Uint8List keyId, Uint8List publicKeyBytes})? parsePublicKeyQr( + String s, + ) { + if (!s.startsWith(_pubKeyPrefix)) return null; + try { + final data = + Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length))); + if (data.length != _keyIdLen + _pubKeyLen) return null; + return ( + keyId: data.sublist(0, _keyIdLen), + publicKeyBytes: data.sublist(_keyIdLen), + ); + } catch (_) { + return null; + } + } + + // ── Encryption ─────────────────────────────────────────────────────────────── + + /// Encrypts [accounts] for the given recipient key pair using ECIES. + /// + /// Returns the QR-code string to show on the sender device. + /// + /// Wire format (base64-encoded): + /// keyId[16] || ephPubKey[32] || nonce[12] || ciphertext || mac[16] + static Future encryptAccounts({ + required Uint8List recipientKeyId, + required Uint8List recipientPublicKeyBytes, + required List accounts, + }) async { + // Build plaintext JSON. + final plaintext = utf8.encode( + jsonEncode({ + 'v': 2, + 'issuedAt': DateTime.now().toUtc().toIso8601String(), + 'accounts': accounts + .map((a) => {'account': a.accountJson, 'password': a.password}) + .toList(), + }), + ); + + // Ephemeral sender key pair for forward-secrecy. + final ephKeyPair = await _x25519.newKeyPair(); + final ephPub = await ephKeyPair.extractPublicKey(); + + // ECDH: shared secret = X25519(ephPriv, recipientPub). + final sharedSecret = await _x25519.sharedSecretKey( + keyPair: ephKeyPair, + remotePublicKey: SimplePublicKey( + recipientPublicKeyBytes, + type: KeyPairType.x25519, + ), + ); + + // Derive AES key via HKDF-SHA256. + final aesKey = await _hkdf.deriveKey( + secretKey: sharedSecret, + nonce: recipientKeyId, + info: utf8.encode('sharedinbox-account-transfer'), + ); + + // Encrypt with AES-256-GCM. + final nonce = Uint8List(_nonceLen); + for (var i = 0; i < _nonceLen; i++) { + nonce[i] = _rng.nextInt(256); + } + + final box = await _aesGcm.encrypt( + plaintext, + secretKey: aesKey, + nonce: nonce, + ); + + // Pack wire format. + final ephPubBytes = Uint8List.fromList(ephPub.bytes); + final cipherBytes = Uint8List.fromList(box.cipherText); + final macBytes = Uint8List.fromList(box.mac.bytes); + + final out = Uint8List( + _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length + _macLen, + ) + ..setAll(0, recipientKeyId) + ..setAll(_keyIdLen, ephPubBytes) + ..setAll(_keyIdLen + _pubKeyLen, nonce) + ..setAll(_keyIdLen + _pubKeyLen + _nonceLen, cipherBytes) + ..setAll( + _keyIdLen + _pubKeyLen + _nonceLen + cipherBytes.length, + macBytes, + ); + + return '$_encAccountsPrefix${base64.encode(out)}'; + } + + // ── Decryption ─────────────────────────────────────────────────────────────── + + /// Parses and decrypts an encrypted-accounts QR string. + /// + /// Throws [FormatException] if the format is invalid. + /// Throws [SecretBoxAuthenticationError] if authentication fails (tampered). + static Future> decryptAccounts({ + required String qrString, + required Uint8List privateKeyBytes, + required Uint8List publicKeyBytes, + required Uint8List keyId, + }) async { + if (!qrString.startsWith(_encAccountsPrefix)) { + throw const FormatException('Not an encrypted-accounts QR code'); + } + + final Uint8List data; + try { + data = Uint8List.fromList( + base64.decode(qrString.substring(_encAccountsPrefix.length)), + ); + } catch (_) { + throw const FormatException('Invalid base64 in encrypted-accounts QR'); + } + + // Minimum: keyId + ephPubKey + nonce + mac (no ciphertext is valid but odd). + if (data.length < _keyIdLen + _pubKeyLen + _nonceLen + _macLen) { + throw const FormatException('Encrypted-accounts payload too short'); + } + + final embeddedKeyId = data.sublist(0, _keyIdLen); + // Verify that this payload was encrypted for the right key pair. + for (var i = 0; i < _keyIdLen; i++) { + if (embeddedKeyId[i] != keyId[i]) { + throw const FormatException( + 'Key ID mismatch — please scan a fresh public-key QR code', + ); + } + } + + final ephPubBytes = data.sublist(_keyIdLen, _keyIdLen + _pubKeyLen); + final nonce = data.sublist( + _keyIdLen + _pubKeyLen, + _keyIdLen + _pubKeyLen + _nonceLen, + ); + final cipherText = data.sublist( + _keyIdLen + _pubKeyLen + _nonceLen, + data.length - _macLen, + ); + final mac = data.sublist(data.length - _macLen); + + // Reconstruct key pair. + final keyPair = SimpleKeyPairData( + privateKeyBytes, + publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.x25519), + type: KeyPairType.x25519, + ); + + // ECDH. + final sharedSecret = await _x25519.sharedSecretKey( + keyPair: keyPair, + remotePublicKey: SimplePublicKey(ephPubBytes, type: KeyPairType.x25519), + ); + + // Re-derive AES key. + final aesKey = await _hkdf.deriveKey( + secretKey: sharedSecret, + nonce: keyId, + info: utf8.encode('sharedinbox-account-transfer'), + ); + + // Decrypt — throws SecretBoxAuthenticationError if tampered. + final plaintext = await _aesGcm.decrypt( + SecretBox(cipherText, nonce: nonce, mac: Mac(mac)), + secretKey: aesKey, + ); + + // Parse JSON. + final Map json; + try { + json = jsonDecode(utf8.decode(plaintext)) as Map; + } catch (_) { + throw const FormatException('Decrypted payload is not valid JSON'); + } + + if ((json['v'] as int?) != 2) { + throw const FormatException('Unsupported encrypted-accounts version'); + } + + // Verify issuedAt is within 20 minutes. + final issuedAtRaw = json['issuedAt'] as String?; + if (issuedAtRaw != null) { + final issuedAt = DateTime.tryParse(issuedAtRaw); + if (issuedAt != null) { + final age = DateTime.now().toUtc().difference(issuedAt.toUtc()); + if (age.abs() > const Duration(minutes: 20)) { + throw const FormatException( + 'The encrypted payload has expired (older than 20 minutes)', + ); + } + } + } + + final rawAccounts = json['accounts'] as List; + return rawAccounts.map((entry) { + final m = entry as Map; + return AccountPayload( + accountJson: m['account'] as Map, + password: m['password'] as String, + ); + }).toList(); + } +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index c8166f4..9bc0f4a 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -238,6 +238,25 @@ class Drafts extends Table { TextColumn get imapServerId => text().nullable()(); } +/// Ephemeral public/private key pair generated for secure account sharing. +/// Expires after 20 minutes; used to decrypt an incoming encrypted-accounts QR. +@DataClassName('ShareKeyRow') +class ShareKeys extends Table { + /// Random 16-byte key ID, hex-encoded. Identifies which key pair the sender + /// used so the receiver can look it up even if multiple pairs exist. + TextColumn get id => text()(); + + /// Base64-encoded X25519 public key (32 bytes). + TextColumn get publicKey => text()(); + + /// Base64-encoded X25519 private key (32 bytes). + TextColumn get privateKey => text()(); + DateTimeColumn get expiresAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} + @DataClassName('SearchHistoryRow') class SearchHistoryEntries extends Table { IntColumn get id => integer().autoIncrement()(); @@ -286,13 +305,14 @@ class UndoActions extends Table { UndoActions, SearchHistoryEntries, LocalSieveScripts, + ShareKeys, ], ) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 30; + int get schemaVersion => 31; Future _createEmailFts() async { await customStatement(''' @@ -527,6 +547,9 @@ class AppDatabase extends _$AppDatabase { if (from >= 12 && from < 30) { await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs); } + if (from < 31) { + await m.createTable(shareKeys); + } }, ); } diff --git a/lib/data/repositories/share_key_repository_impl.dart b/lib/data/repositories/share_key_repository_impl.dart new file mode 100644 index 0000000..4953141 --- /dev/null +++ b/lib/data/repositories/share_key_repository_impl.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import 'package:sharedinbox/core/repositories/share_key_repository.dart'; +import 'package:sharedinbox/core/services/share_encryption_service.dart'; +import 'package:sharedinbox/data/db/database.dart'; + +/// Drift-backed implementation of [ShareKeyRepository]. +/// +/// Each key pair lives for 20 minutes. Expired rows are pruned whenever a +/// new key pair is created or looked up. +class ShareKeyRepositoryImpl implements ShareKeyRepository { + ShareKeyRepositoryImpl(this._db); + + final AppDatabase _db; + + @override + Future createKeyPair() async { + await _pruneExpired(); + + final material = await ShareEncryptionService.generateKeyPair(); + final keyIdHex = _hex(material.keyId); + final expiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20)); + + await _db.into(_db.shareKeys).insert( + ShareKeysCompanion.insert( + id: keyIdHex, + publicKey: base64.encode(material.publicKeyBytes), + privateKey: base64.encode(material.privateKeyBytes), + expiresAt: expiresAt, + ), + ); + + return material; + } + + @override + Future findByKeyId(Uint8List keyId) async { + await _pruneExpired(); + + final keyIdHex = _hex(keyId); + final row = await (_db.select(_db.shareKeys) + ..where((t) => t.id.equals(keyIdHex))) + .getSingleOrNull(); + + if (row == null) return null; + if (row.expiresAt.isBefore(DateTime.now().toUtc())) return null; + + return ShareKeyMaterial( + keyId: keyId, + publicKeyBytes: Uint8List.fromList(base64.decode(row.publicKey)), + privateKeyBytes: Uint8List.fromList(base64.decode(row.privateKey)), + ); + } + + Future _pruneExpired() async { + await (_db.delete(_db.shareKeys) + ..where( + (t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()), + )) + .go(); + } + + static String _hex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} diff --git a/lib/di.dart b/lib/di.dart index f26948d..6d89106 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -10,6 +10,7 @@ import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart'; +import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; @@ -28,6 +29,7 @@ import 'package:sharedinbox/data/repositories/draft_repository_impl.dart'; import 'package:sharedinbox/data/repositories/email_repository_impl.dart'; import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart'; import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart'; +import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; import 'package:sharedinbox/data/repositories/undo_repository_impl.dart'; import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; @@ -61,6 +63,10 @@ final accountRepositoryProvider = Provider((ref) { ); }); +final shareKeyRepositoryProvider = Provider((ref) { + return ShareKeyRepositoryImpl(ref.watch(dbProvider)); +}); + final mailboxRepositoryProvider = Provider((ref) { return MailboxRepositoryImpl( ref.watch(dbProvider), diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 10244c7..9cf5fcc 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -3,9 +3,9 @@ import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/sieve_script.dart'; import 'package:sharedinbox/ui/screens/about_screen.dart'; -import 'package:sharedinbox/ui/screens/account_export_screen.dart'; -import 'package:sharedinbox/ui/screens/account_import_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; +import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; +import 'package:sharedinbox/ui/screens/account_send_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; import 'package:sharedinbox/ui/screens/changelog_screen.dart'; @@ -37,8 +37,12 @@ final router = GoRouter( builder: (ctx, state) => const AddAccountScreen(), ), GoRoute( - path: 'import', - builder: (ctx, state) => const AccountImportScreen(), + path: 'receive', + builder: (ctx, state) => const AccountReceiveScreen(), + ), + GoRoute( + path: 'send', + builder: (ctx, state) => const AccountSendScreen(), ), GoRoute( path: 'undo-log', @@ -58,12 +62,6 @@ final router = GoRouter( accountId: state.pathParameters['accountId']!, ), ), - GoRoute( - path: ':accountId/export', - builder: (ctx, state) => AccountExportScreen( - accountId: state.pathParameters['accountId']!, - ), - ), GoRoute( path: ':accountId/sync-log', builder: (ctx, state) => diff --git a/lib/ui/screens/account_export_screen.dart b/lib/ui/screens/account_export_screen.dart deleted file mode 100644 index 0fde9f9..0000000 --- a/lib/ui/screens/account_export_screen.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:qr_flutter/qr_flutter.dart'; - -import 'package:sharedinbox/di.dart'; - -class AccountExportScreen extends ConsumerStatefulWidget { - const AccountExportScreen({super.key, required this.accountId}); - - final String accountId; - - @override - ConsumerState createState() => - _AccountExportScreenState(); -} - -class _AccountExportScreenState extends ConsumerState { - bool _loading = true; - String? _exportCode; - String? _error; - - @override - void initState() { - super.initState(); - unawaited(_load()); - } - - Future _load() async { - try { - final repo = ref.read(accountRepositoryProvider); - final account = await repo.getAccount(widget.accountId); - if (account == null) { - setState(() { - _error = 'Account not found'; - _loading = false; - }); - return; - } - final password = await repo.getPassword(widget.accountId); - final payload = jsonEncode({ - 'v': 1, - 'account': account.toJson(), - 'password': password, - }); - setState(() { - _exportCode = payload; - _loading = false; - }); - } catch (e) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Export account')), - body: _buildBody(context), - ); - } - - Widget _buildBody(BuildContext context) { - if (_loading) return const Center(child: CircularProgressIndicator()); - if (_error != null) { - return Center(child: Text('Error: $_error')); - } - - final theme = Theme.of(context); - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Card( - color: theme.colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(Icons.warning_amber, color: theme.colorScheme.error), - const SizedBox(width: 8), - const Expanded( - child: Text( - 'This code contains your password. Keep it private.', - ), - ), - ], - ), - ), - ), - const SizedBox(height: 24), - Center( - child: QrImageView( - key: const Key('accountQrCode'), - data: _exportCode!, - size: 260, - ), - ), - const SizedBox(height: 24), - OutlinedButton.icon( - key: const Key('copyCodeButton'), - icon: const Icon(Icons.copy), - label: const Text('Copy code'), - onPressed: () { - unawaited(Clipboard.setData(ClipboardData(text: _exportCode!))); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Code copied to clipboard')), - ); - }, - ), - const SizedBox(height: 8), - const Text( - 'Scan the QR code on your other device, or tap "Copy code" and ' - 'paste it into the "Import account" screen.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 12), - ), - ], - ), - ); - } -} diff --git a/lib/ui/screens/account_import_screen.dart b/lib/ui/screens/account_import_screen.dart deleted file mode 100644 index a6b0a1b..0000000 --- a/lib/ui/screens/account_import_screen.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:sharedinbox/core/models/account.dart'; -import 'package:sharedinbox/di.dart'; - -class AccountImportScreen extends ConsumerStatefulWidget { - const AccountImportScreen({super.key}); - - @override - ConsumerState createState() => - _AccountImportScreenState(); -} - -class _AccountImportScreenState extends ConsumerState { - final _ctrl = TextEditingController(); - Account? _parsed; - String? _parsedPassword; - String? _parseError; - bool _saving = false; - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - void _onTextChanged(String value) { - final text = value.trim(); - if (text.isEmpty) { - setState(() { - _parsed = null; - _parsedPassword = null; - _parseError = null; - }); - return; - } - try { - final json = jsonDecode(text) as Map; - if ((json['v'] as int?) != 1) { - throw const FormatException('Unknown version'); - } - final account = Account.fromJson( - json['account'] as Map, - ); - final password = json['password'] as String; - setState(() { - _parsed = Account( - id: DateTime.now().millisecondsSinceEpoch.toString(), - displayName: account.displayName, - email: account.email, - username: account.username, - type: account.type, - imapHost: account.imapHost, - imapPort: account.imapPort, - imapSsl: account.imapSsl, - smtpHost: account.smtpHost, - smtpPort: account.smtpPort, - smtpSsl: account.smtpSsl, - manageSieveHost: account.manageSieveHost, - manageSievePort: account.manageSievePort, - manageSieveSsl: account.manageSieveSsl, - jmapUrl: account.jmapUrl, - ); - _parsedPassword = password; - _parseError = null; - }); - } catch (_) { - setState(() { - _parsed = null; - _parsedPassword = null; - _parseError = - 'Invalid code — paste the full text from "Export account"'; - }); - } - } - - Future _import() async { - if (_parsed == null || _parsedPassword == null) return; - setState(() => _saving = true); - try { - await ref - .read(accountRepositoryProvider) - .addAccount(_parsed!, _parsedPassword!); - if (mounted) context.pop(); - } catch (e) { - if (mounted) { - setState(() => _saving = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Import failed: $e')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar(title: const Text('Import account')), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'On your other device, open the account menu and tap ' - '"Export account". Then copy the code and paste it below.', - ), - const SizedBox(height: 16), - TextField( - key: const Key('importCodeField'), - controller: _ctrl, - maxLines: 6, - onChanged: _onTextChanged, - decoration: const InputDecoration( - labelText: 'Account code', - border: OutlineInputBorder(), - hintText: 'Paste code here', - ), - ), - if (_parseError != null) ...[ - const SizedBox(height: 8), - Text( - _parseError!, - style: TextStyle(color: theme.colorScheme.error), - ), - ], - if (_parsed != null) ...[ - const SizedBox(height: 16), - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ready to import:', - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 4), - Text(_parsed!.displayName), - Text(_parsed!.email), - Text( - _parsed!.type == AccountType.jmap ? 'JMAP' : 'IMAP', - ), - ], - ), - ), - ), - ], - const SizedBox(height: 16), - FilledButton( - key: const Key('importButton'), - onPressed: (_parsed != null && !_saving) ? _import : null, - child: _saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Import'), - ), - ], - ), - ), - ); - } -} diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 5bafa41..a6b1663 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -34,6 +34,14 @@ class AccountListScreen extends ConsumerWidget { style: TextStyle(color: Colors.white, fontSize: 24), ), ), + ListTile( + leading: const Icon(Icons.qr_code_scanner), + title: const Text('Receive accounts'), + onTap: () { + Navigator.pop(context); + unawaited(context.push('/accounts/receive')); + }, + ), ListTile( leading: const Icon(Icons.history), title: const Text('Undo Log'), @@ -180,8 +188,8 @@ class _AccountTile extends ConsumerWidget { child: Text('Local email filters'), ), const PopupMenuItem( - value: _AccountAction.export, - child: Text('Export account'), + value: _AccountAction.send, + child: Text('Send accounts'), ), const PopupMenuDivider(), const PopupMenuItem( @@ -253,8 +261,8 @@ class _AccountTile extends ConsumerWidget { case _AccountAction.emailFiltersLocal: await context.push('/accounts/${account.id}/sieve/local'); break; - case _AccountAction.export: - await context.push('/accounts/${account.id}/export'); + case _AccountAction.send: + await context.push('/accounts/send'); break; case _AccountAction.delete: final confirmed = await showDialog( @@ -398,7 +406,7 @@ enum _AccountAction { edit, emailFiltersRemote, emailFiltersLocal, - export, + send, delete, } diff --git a/lib/ui/screens/account_receive_screen.dart b/lib/ui/screens/account_receive_screen.dart new file mode 100644 index 0000000..897726c --- /dev/null +++ b/lib/ui/screens/account_receive_screen.dart @@ -0,0 +1,387 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/core/services/share_encryption_service.dart'; +import 'package:sharedinbox/di.dart'; + +/// Receiving side of the secure account-sharing flow. +/// +/// Step 1 – generates an X25519 key pair with a 20-minute lifetime and shows +/// the public key as a QR code to be scanned by the sender. +/// +/// Step 2 – scans the encrypted-accounts QR code shown by the sender, decrypts +/// it using the private key, and imports the accounts. +class AccountReceiveScreen extends ConsumerStatefulWidget { + const AccountReceiveScreen({super.key}); + + @override + ConsumerState createState() => + _AccountReceiveScreenState(); +} + +enum _Step { generatingKey, showingPubKey, scanning, importing, done, error } + +class _AccountReceiveScreenState extends ConsumerState { + _Step _step = _Step.generatingKey; + ShareKeyMaterial? _keyMaterial; + String? _pubKeyQr; + String? _errorMessage; + bool _scannerActive = false; + + MobileScannerController? _scannerController; + + @override + void initState() { + super.initState(); + unawaited(_generateKey()); + } + + @override + void dispose() { + final ctrl = _scannerController; + if (ctrl != null) unawaited(ctrl.dispose()); + super.dispose(); + } + + Future _generateKey() async { + try { + final repo = ref.read(shareKeyRepositoryProvider); + final material = await repo.createKeyPair(); + final qr = ShareEncryptionService.encodePublicKeyQr( + material.keyId, + material.publicKeyBytes, + ); + setState(() { + _keyMaterial = material; + _pubKeyQr = qr; + _step = _Step.showingPubKey; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _step = _Step.error; + }); + } + } + + void _startScanning() { + setState(() { + _step = _Step.scanning; + _scannerActive = true; + _scannerController = MobileScannerController(); + }); + } + + Future _onScanned(String rawValue) async { + if (!_scannerActive) return; + _scannerActive = false; + await _scannerController?.stop(); + + setState(() => _step = _Step.importing); + + try { + final material = _keyMaterial!; + final accounts = await ShareEncryptionService.decryptAccounts( + qrString: rawValue, + privateKeyBytes: material.privateKeyBytes, + publicKeyBytes: material.publicKeyBytes, + keyId: material.keyId, + ); + + final repo = ref.read(accountRepositoryProvider); + for (final ap in accounts) { + final account = Account.fromJson(ap.accountJson); + final newAccount = Account( + id: DateTime.now().millisecondsSinceEpoch.toString(), + displayName: account.displayName, + email: account.email, + username: account.username, + type: account.type, + imapHost: account.imapHost, + imapPort: account.imapPort, + imapSsl: account.imapSsl, + smtpHost: account.smtpHost, + smtpPort: account.smtpPort, + smtpSsl: account.smtpSsl, + manageSieveHost: account.manageSieveHost, + manageSievePort: account.manageSievePort, + manageSieveSsl: account.manageSieveSsl, + jmapUrl: account.jmapUrl, + ); + await repo.addAccount(newAccount, ap.password); + } + + if (mounted) { + setState(() => _step = _Step.done); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Imported ${accounts.length} account${accounts.length == 1 ? '' : 's'} successfully.', + ), + ), + ); + context.pop(); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = _friendlyError(e); + _scannerActive = false; + // Let user retry from the pubkey step. + _step = _Step.showingPubKey; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_friendlyError(e)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + String _friendlyError(Object e) { + final s = e.toString(); + if (s.contains('expired') || s.contains('older than')) { + return 'The QR code has expired. Ask the sender to generate a new one.'; + } + if (s.contains('Key ID mismatch') || s.contains('Unknown')) { + return 'QR code does not match this session. Regenerate the public key and try again.'; + } + if (s.contains('authentication') || + s.contains('mac') || + s.contains('SecretBox')) { + return 'Authentication failed — the QR code may have been tampered with.'; + } + return 'Import failed: $s'; + } + + // ── Build ────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Receive accounts')), + body: switch (_step) { + _Step.generatingKey => const Center(child: CircularProgressIndicator()), + _Step.showingPubKey => _buildPubKeyView(context), + _Step.scanning => _buildScannerView(context), + _Step.importing => const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Importing accounts…'), + ], + ), + ), + _Step.done => const Center( + child: Icon( + Icons.check_circle, + size: 64, + color: Colors.green, + ), + ), + _Step.error => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Error: $_errorMessage'), + ), + ), + }, + ); + } + + Widget _buildPubKeyView(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Step 1 of 2 — Show this QR code to the sender', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'The sender scans this code, selects the account(s) to transfer, ' + 'and shows an encrypted QR code. Then come back here for step 2.', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Center( + child: QrImageView( + key: const Key('pubKeyQrCode'), + data: _pubKeyQr!, + size: 260, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + icon: const Icon(Icons.copy), + label: const Text('Copy public key'), + onPressed: () { + unawaited(Clipboard.setData(ClipboardData(text: _pubKeyQr!))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Public key copied to clipboard')), + ); + }, + ), + const SizedBox(height: 8), + const _ExpiryHint(), + const SizedBox(height: 32), + if (_errorMessage != null) ...[ + Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ], + FilledButton.icon( + key: const Key('scanEncryptedButton'), + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Step 2 — Scan encrypted QR code'), + onPressed: _startScanning, + ), + ], + ), + ); + } + + Widget _buildScannerView(BuildContext context) { + // On platforms where the camera scanner is not available (Linux desktop), + // fall back to a text-input field. + if (!_cameraScanSupported()) { + return _buildTextFallbackView(context); + } + + return Stack( + children: [ + MobileScanner( + controller: _scannerController!, + onDetect: (capture) { + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw != null) unawaited(_onScanned(raw)); + }, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: const Text( + 'Point the camera at the encrypted QR code from the sender\'s device', + style: TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + Positioned( + bottom: 32, + left: 16, + right: 16, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundColor: Colors.black54, + foregroundColor: Colors.white, + ), + onPressed: () { + final ctrl = _scannerController; + if (ctrl != null) unawaited(ctrl.dispose()); + _scannerController = null; + setState(() { + _scannerActive = false; + _step = _Step.showingPubKey; + }); + }, + child: const Text('Cancel'), + ), + ), + ], + ); + } + + Widget _buildTextFallbackView(BuildContext context) { + final ctrl = TextEditingController(); + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Paste the encrypted code from the sender\'s device', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextField( + key: const Key('encryptedCodeField'), + controller: ctrl, + maxLines: 6, + decoration: const InputDecoration( + labelText: 'Encrypted code', + border: OutlineInputBorder(), + hintText: 'sharedinbox.de:encrypted-accounts:v1:…', + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + final text = ctrl.text.trim(); + if (text.isNotEmpty) unawaited(_onScanned(text)); + }, + child: const Text('Import'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => setState(() { + _scannerActive = false; + _step = _Step.showingPubKey; + }), + child: const Text('Cancel'), + ), + ], + ), + ); + } +} + +bool _cameraScanSupported() => + Platform.isAndroid || + Platform.isIOS || + Platform.isMacOS || + Platform.isWindows; + +class _ExpiryHint extends StatelessWidget { + const _ExpiryHint(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]), + const SizedBox(width: 4), + Text( + 'This key expires in 20 minutes', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ); + } +} diff --git a/lib/ui/screens/account_send_screen.dart b/lib/ui/screens/account_send_screen.dart new file mode 100644 index 0000000..d3eaeaf --- /dev/null +++ b/lib/ui/screens/account_send_screen.dart @@ -0,0 +1,351 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/core/services/share_encryption_service.dart'; +import 'package:sharedinbox/di.dart'; + +/// Sending side of the secure account-sharing flow. +/// +/// Step 1 – scans (or pastes) the receiver's public-key QR code. +/// +/// Step 2 – if more than one account exists, the user selects which accounts +/// to transfer (auto-selected when only one account is present). +/// +/// Step 3 – shows the encrypted-accounts QR code for the receiver to scan. +class AccountSendScreen extends ConsumerStatefulWidget { + const AccountSendScreen({super.key}); + + @override + ConsumerState createState() => _AccountSendScreenState(); +} + +enum _Step { scanning, selectAccounts, showEncrypted, error } + +class _AccountSendScreenState extends ConsumerState { + _Step _step = _Step.scanning; + + // Set after scanning the pubkey QR. + Uint8List? _recipientKeyId; + Uint8List? _recipientPublicKey; + + // All available accounts + the selection (for step 2). + List _accounts = []; + final Set _selectedIds = {}; + + // Set after encryption (step 3). + String? _encryptedQr; + String? _errorMessage; + bool _scannerActive = true; + + MobileScannerController? _scannerController; + + @override + void initState() { + super.initState(); + if (_cameraScanSupported()) { + _scannerController = MobileScannerController(); + } + } + + @override + void dispose() { + final ctrl = _scannerController; + if (ctrl != null) unawaited(ctrl.dispose()); + super.dispose(); + } + + // ── Step 1: scan pubkey QR ────────────────────────────────────────────────── + + Future _onPubKeyScanned(String rawValue) async { + if (!_scannerActive) return; + _scannerActive = false; + await _scannerController?.stop(); + + final parsed = ShareEncryptionService.parsePublicKeyQr(rawValue); + if (parsed == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Not a valid SharedInbox public-key QR code. ' + 'Ask the receiver to show step 1 of "Receive accounts".', + ), + ), + ); + // Allow retry. + setState(() => _scannerActive = true); + await _scannerController?.start(); + } + return; + } + + // Load all available accounts. + final accounts = + await ref.read(accountRepositoryProvider).observeAccounts().first; + + if (!mounted) return; + + if (accounts.isEmpty) { + setState(() { + _errorMessage = 'No accounts to send.'; + _step = _Step.error; + }); + return; + } + + setState(() { + _recipientKeyId = parsed.keyId; + _recipientPublicKey = parsed.publicKeyBytes; + _accounts = accounts; + }); + + if (accounts.length == 1) { + // Auto-select the only account; skip the selection step. + _selectedIds.add(accounts.first.id); + await _encryptAndShow(); + } else { + setState(() { + _selectedIds.addAll(accounts.map((a) => a.id)); + _step = _Step.selectAccounts; + }); + } + } + + // ── Step 2: account selection ─────────────────────────────────────────────── + + Future _encryptAndShow() async { + final repo = ref.read(accountRepositoryProvider); + final selected = _accounts.where((a) => _selectedIds.contains(a.id)); + + final payloads = []; + for (final account in selected) { + final password = await repo.getPassword(account.id); + payloads.add( + AccountPayload( + accountJson: account.toJson(), + password: password, + ), + ); + } + + try { + final qr = await ShareEncryptionService.encryptAccounts( + recipientKeyId: _recipientKeyId!, + recipientPublicKeyBytes: _recipientPublicKey!, + accounts: payloads, + ); + if (mounted) { + setState(() { + _encryptedQr = qr; + _step = _Step.showEncrypted; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString(); + _step = _Step.error; + }); + } + } + } + + // ── Build ─────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Send accounts')), + body: switch (_step) { + _Step.scanning => _buildScanStep(context), + _Step.selectAccounts => _buildSelectStep(context), + _Step.showEncrypted => _buildEncryptedQrStep(context), + _Step.error => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Error: $_errorMessage'), + ), + ), + }, + ); + } + + Widget _buildScanStep(BuildContext context) { + if (!_cameraScanSupported()) { + return _buildTextFallbackView(context); + } + + return Stack( + children: [ + MobileScanner( + controller: _scannerController!, + onDetect: (capture) { + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw != null) unawaited(_onPubKeyScanned(raw)); + }, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: const Text( + 'Point the camera at the public-key QR code shown by the receiver', + style: TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + ], + ); + } + + Widget _buildTextFallbackView(BuildContext context) { + final ctrl = TextEditingController(); + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Paste the public key shown by the receiver\'s "Receive accounts" screen.', + ), + const SizedBox(height: 16), + TextField( + key: const Key('pubKeyInputField'), + controller: ctrl, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Public key', + border: OutlineInputBorder(), + hintText: 'sharedinbox.de:pubkey:v1:…', + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + final text = ctrl.text.trim(); + if (text.isNotEmpty) unawaited(_onPubKeyScanned(text)); + }, + child: const Text('Continue'), + ), + ], + ), + ); + } + + Widget _buildSelectStep(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Select accounts to send', + style: theme.textTheme.titleMedium, + ), + ), + Expanded( + child: ListView( + children: _accounts.map((account) { + final selected = _selectedIds.contains(account.id); + return CheckboxListTile( + value: selected, + title: Text(account.displayName), + subtitle: Text(account.email), + onChanged: (v) { + setState(() { + if (v == true) { + _selectedIds.add(account.id); + } else { + _selectedIds.remove(account.id); + } + }); + }, + ); + }).toList(), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton( + key: const Key('sendSelectedButton'), + onPressed: _selectedIds.isEmpty + ? null + : () => unawaited(_encryptAndShow()), + child: const Text('Encrypt & show QR'), + ), + ), + ], + ); + } + + Widget _buildEncryptedQrStep(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Step 3 — Show this QR code to the receiver', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'The receiver taps "Step 2 — Scan encrypted QR code" and scans this.', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Center( + child: QrImageView( + key: const Key('encryptedAccountsQrCode'), + data: _encryptedQr!, + size: 280, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + key: const Key('copyEncryptedButton'), + icon: const Icon(Icons.copy), + label: const Text('Copy encrypted code'), + onPressed: () { + unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Encrypted code copied to clipboard', + ), + ), + ); + }, + ), + const SizedBox(height: 8), + Text( + 'This code contains encrypted account data. It is safe to display ' + 'briefly — only the receiver\'s device can decrypt it.', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +bool _cameraScanSupported() => + Platform.isAndroid || + Platform.isIOS || + Platform.isMacOS || + Platform.isWindows; diff --git a/lib/ui/screens/add_account_screen.dart b/lib/ui/screens/add_account_screen.dart index 89d7f93..01ed21c 100644 --- a/lib/ui/screens/add_account_screen.dart +++ b/lib/ui/screens/add_account_screen.dart @@ -299,8 +299,8 @@ class _AddAccountScreenState extends ConsumerState { OutlinedButton.icon( key: const Key('importAccountButton'), icon: const Icon(Icons.qr_code_scanner), - label: const Text('Import account'), - onPressed: () => context.push('/accounts/import'), + label: const Text('Receive account'), + onPressed: () => context.push('/accounts/receive'), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 75eafe7..9059cc1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,12 @@ dependencies: # QR code generation for account sharing qr_flutter: ^4.1.0 + # Public-key encryption for secure account sharing (ECIES: X25519 + AES-256-GCM) + cryptography: ^2.7.0 + + # QR code scanning (camera) for secure account import + mobile_scanner: ^5.0.0 + # HTML rendering for email bodies webview_flutter: ^4.0.0 url_launcher: ^6.3.2 diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 26b7b03..a354628 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -15,6 +15,7 @@ const _noCode = { 'lib/core/repositories/draft_repository.dart', 'lib/core/repositories/email_repository.dart', 'lib/core/repositories/mailbox_repository.dart', + 'lib/core/repositories/share_key_repository.dart', 'lib/core/repositories/sync_log_repository.dart', 'lib/core/repositories/undo_repository.dart', 'lib/core/repositories/search_history_repository.dart', @@ -32,9 +33,9 @@ const _excluded = { 'lib/di.dart', 'lib/main.dart', 'lib/ui/router.dart', - 'lib/ui/screens/account_export_screen.dart', - 'lib/ui/screens/account_import_screen.dart', 'lib/ui/screens/account_list_screen.dart', + 'lib/ui/screens/account_receive_screen.dart', + 'lib/ui/screens/account_send_screen.dart', 'lib/ui/screens/add_account_screen.dart', 'lib/ui/screens/address_emails_screen.dart', 'lib/ui/screens/changelog_screen.dart', @@ -63,6 +64,7 @@ const _excluded = { 'lib/data/repositories/account_repository_impl.dart', 'lib/data/repositories/email_repository_impl.dart', 'lib/data/repositories/mailbox_repository_impl.dart', + 'lib/data/repositories/share_key_repository_impl.dart', 'lib/data/repositories/sync_log_repository_impl.dart', 'lib/data/repositories/undo_repository_impl.dart', 'lib/data/repositories/search_history_repository_impl.dart', diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 55d2d19..8446b40 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 30); + expect(db.schemaVersion, 31); await db.close(); }); @@ -382,7 +382,7 @@ void main() { if (dbFile.existsSync()) dbFile.deleteSync(); }); - test('fresh install creates all tables at schemaVersion 30', () async { + test('fresh install creates all tables at schemaVersion 31', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get(); @@ -407,6 +407,7 @@ void main() { 'undo_actions', 'search_history_entries', 'local_sieve_scripts', // v29 + 'share_keys', // v31 ]), ); diff --git a/test/unit/share_encryption_service_test.dart b/test/unit/share_encryption_service_test.dart new file mode 100644 index 0000000..552bb96 --- /dev/null +++ b/test/unit/share_encryption_service_test.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sharedinbox/core/services/share_encryption_service.dart'; +import 'package:test/test.dart'; + +void main() { + group('ShareEncryptionService', () { + // ── generateKeyPair ───────────────────────────────────────────────────── + + test('generateKeyPair returns 16-byte key ID and 32-byte keys', () async { + final m = await ShareEncryptionService.generateKeyPair(); + expect(m.keyId.length, 16); + expect(m.publicKeyBytes.length, 32); + expect(m.privateKeyBytes.length, 32); + }); + + test('generateKeyPair returns unique key IDs', () async { + final a = await ShareEncryptionService.generateKeyPair(); + final b = await ShareEncryptionService.generateKeyPair(); + expect(a.keyId, isNot(equals(b.keyId))); + }); + + // ── encodePublicKeyQr / parsePublicKeyQr ──────────────────────────────── + + test('encodePublicKeyQr produces expected prefix', () async { + final m = await ShareEncryptionService.generateKeyPair(); + final qr = ShareEncryptionService.encodePublicKeyQr( + m.keyId, + m.publicKeyBytes, + ); + expect(qr, startsWith('sharedinbox.de:pubkey:v1:')); + }); + + test('parsePublicKeyQr round-trips correctly', () async { + final m = await ShareEncryptionService.generateKeyPair(); + final qr = ShareEncryptionService.encodePublicKeyQr( + m.keyId, + m.publicKeyBytes, + ); + final parsed = ShareEncryptionService.parsePublicKeyQr(qr); + expect(parsed, isNotNull); + expect(parsed!.keyId, equals(m.keyId)); + expect(parsed.publicKeyBytes, equals(m.publicKeyBytes)); + }); + + test('parsePublicKeyQr returns null for invalid input', () { + expect(ShareEncryptionService.parsePublicKeyQr('not-valid'), isNull); + expect( + ShareEncryptionService.parsePublicKeyQr( + 'sharedinbox.de:pubkey:v1:!!!', + ), + isNull, + ); + expect( + ShareEncryptionService.parsePublicKeyQr( + 'sharedinbox.de:pubkey:v1:${base64.encode(Uint8List(10))}', + ), + isNull, + ); + }); + + // ── encrypt / decrypt round-trip ──────────────────────────────────────── + + test('encrypt + decrypt round-trip restores account payload', () async { + final m = await ShareEncryptionService.generateKeyPair(); + + final accounts = [ + const AccountPayload( + accountJson: { + 'id': 'acc-1', + 'displayName': 'Alice', + 'email': 'alice@example.com', + 'username': '', + 'type': 'imap', + 'imapHost': 'imap.example.com', + 'imapPort': 993, + 'imapSsl': true, + 'smtpHost': 'smtp.example.com', + 'smtpPort': 465, + 'smtpSsl': true, + 'manageSieveHost': '', + 'manageSievePort': 4190, + 'manageSieveSsl': true, + 'manageSieveAvailable': null, + 'jmapUrl': null, + 'verbose': false, + }, + password: 'hunter2', + ), + ]; + + final qr = await ShareEncryptionService.encryptAccounts( + recipientKeyId: m.keyId, + recipientPublicKeyBytes: m.publicKeyBytes, + accounts: accounts, + ); + + expect(qr, startsWith('sharedinbox.de:encrypted-accounts:v1:')); + + final decrypted = await ShareEncryptionService.decryptAccounts( + qrString: qr, + privateKeyBytes: m.privateKeyBytes, + publicKeyBytes: m.publicKeyBytes, + keyId: m.keyId, + ); + + expect(decrypted, hasLength(1)); + expect(decrypted.first.password, 'hunter2'); + expect(decrypted.first.accountJson['email'], 'alice@example.com'); + expect(decrypted.first.accountJson['displayName'], 'Alice'); + }); + + test('decrypt multiple accounts', () async { + final m = await ShareEncryptionService.generateKeyPair(); + + final accounts = [ + const AccountPayload( + accountJson: { + 'id': '1', + 'email': 'a@x.com', + 'displayName': 'A', + 'username': '', + 'type': 'imap', + 'imapHost': 'h', + 'imapPort': 993, + 'imapSsl': true, + 'smtpHost': 'h', + 'smtpPort': 465, + 'smtpSsl': true, + 'manageSieveHost': '', + 'manageSievePort': 4190, + 'manageSieveSsl': true, + 'manageSieveAvailable': null, + 'jmapUrl': null, + 'verbose': false, + }, + password: 'pw1', + ), + const AccountPayload( + accountJson: { + 'id': '2', + 'email': 'b@x.com', + 'displayName': 'B', + 'username': '', + 'type': 'imap', + 'imapHost': 'h', + 'imapPort': 993, + 'imapSsl': true, + 'smtpHost': 'h', + 'smtpPort': 465, + 'smtpSsl': true, + 'manageSieveHost': '', + 'manageSievePort': 4190, + 'manageSieveSsl': true, + 'manageSieveAvailable': null, + 'jmapUrl': null, + 'verbose': false, + }, + password: 'pw2', + ), + ]; + + final qr = await ShareEncryptionService.encryptAccounts( + recipientKeyId: m.keyId, + recipientPublicKeyBytes: m.publicKeyBytes, + accounts: accounts, + ); + + final decrypted = await ShareEncryptionService.decryptAccounts( + qrString: qr, + privateKeyBytes: m.privateKeyBytes, + publicKeyBytes: m.publicKeyBytes, + keyId: m.keyId, + ); + + expect(decrypted, hasLength(2)); + expect(decrypted.map((a) => a.password), containsAll(['pw1', 'pw2'])); + }); + + test('decrypt rejects wrong key ID', () async { + final sender = await ShareEncryptionService.generateKeyPair(); + final other = await ShareEncryptionService.generateKeyPair(); + + final qr = await ShareEncryptionService.encryptAccounts( + recipientKeyId: sender.keyId, + recipientPublicKeyBytes: sender.publicKeyBytes, + accounts: [const AccountPayload(accountJson: {}, password: 'pw')], + ); + + expect( + () => ShareEncryptionService.decryptAccounts( + qrString: qr, + privateKeyBytes: other.privateKeyBytes, + publicKeyBytes: other.publicKeyBytes, + keyId: other.keyId, // different key ID + ), + throwsA(isA()), + ); + }); + + test('decrypt rejects invalid prefix', () async { + final m = await ShareEncryptionService.generateKeyPair(); + expect( + () => ShareEncryptionService.decryptAccounts( + qrString: 'not-valid', + privateKeyBytes: m.privateKeyBytes, + publicKeyBytes: m.publicKeyBytes, + keyId: m.keyId, + ), + throwsA(isA()), + ); + }); + + test('decrypt rejects tampered ciphertext', () async { + final m = await ShareEncryptionService.generateKeyPair(); + + final qr = await ShareEncryptionService.encryptAccounts( + recipientKeyId: m.keyId, + recipientPublicKeyBytes: m.publicKeyBytes, + accounts: [ + const AccountPayload( + accountJson: {'id': '1', 'email': 'a@x.com'}, + password: 'pw', + ), + ], + ); + + // Flip a byte in the base64 payload. + const prefix = 'sharedinbox.de:encrypted-accounts:v1:'; + final raw = qr.substring(prefix.length); + final bytes = base64.decode(raw); + bytes[40] ^= 0xFF; // Corrupt a byte in the ciphertext area. + final tampered = '$prefix${base64.encode(bytes)}'; + + await expectLater( + ShareEncryptionService.decryptAccounts( + qrString: tampered, + privateKeyBytes: m.privateKeyBytes, + publicKeyBytes: m.publicKeyBytes, + keyId: m.keyId, + ), + throwsA(anything), + ); + }); + }); +} diff --git a/test/widget/account_export_screen_test.dart b/test/widget/account_export_screen_test.dart index cc8ae67..35d2220 100644 --- a/test/widget/account_export_screen_test.dart +++ b/test/widget/account_export_screen_test.dart @@ -1,162 +1,155 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/core/services/share_encryption_service.dart'; import 'helpers.dart'; void main() { - group('AccountExportScreen', () { - const account = Account( - id: 'acc-1', - displayName: 'Alice', - email: 'alice@example.com', - imapHost: 'imap.example.com', - smtpHost: 'smtp.example.com', - ); - - testWidgets('shows QR code and copy button after loading', (tester) async { + group('AccountReceiveScreen', () { + testWidgets('shows pubkey QR code and scan button after key generation', ( + tester, + ) async { await tester.pumpWidget( buildApp( - initialLocation: '/accounts/acc-1/export', - overrides: baseOverrides(accounts: [account]), + initialLocation: '/accounts/receive', + overrides: baseOverrides(), ), ); + // Allow async key generation to complete. await tester.pumpAndSettle(); - expect(find.byKey(const Key('accountQrCode')), findsOneWidget); - expect(find.byKey(const Key('copyCodeButton')), findsOneWidget); + expect(find.byKey(const Key('pubKeyQrCode')), findsOneWidget); + expect(find.byKey(const Key('scanEncryptedButton')), findsOneWidget); }); - testWidgets('shows password warning', (tester) async { + testWidgets('shows 20-minute expiry hint', (tester) async { await tester.pumpWidget( buildApp( - initialLocation: '/accounts/acc-1/export', - overrides: baseOverrides(accounts: [account]), + initialLocation: '/accounts/receive', + overrides: baseOverrides(), ), ); await tester.pumpAndSettle(); - expect( - find.textContaining('password'), - findsAtLeastNWidgets(1), - ); + expect(find.textContaining('20 minutes'), findsOneWidget); }); }); - group('AccountImportScreen', () { - testWidgets('shows instruction text and disabled import button', ( + group('AccountSendScreen', () { + testWidgets('shows camera scanner (or text fallback) on load', ( tester, ) async { await tester.pumpWidget( buildApp( - initialLocation: '/accounts/import', - overrides: baseOverrides(), + initialLocation: '/accounts/send', + overrides: baseOverrides( + accounts: [ + const Account( + id: 'acc-1', + displayName: 'Alice', + email: 'alice@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ), + ], + ), ), ); await tester.pumpAndSettle(); - expect(find.text('Import account'), findsOneWidget); - expect(find.byKey(const Key('importCodeField')), findsOneWidget); - - final importBtn = tester.widget( - find.byKey(const Key('importButton')), - ); - expect(importBtn.onPressed, isNull); + // On Linux (desktop without camera), the text-fallback field appears. + // On mobile, the MobileScanner widget would be shown. + // Either way the screen renders without crash. + expect(find.byType(Scaffold), findsAtLeastNWidgets(1)); }); - testWidgets('invalid JSON shows error message', (tester) async { - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/import', - overrides: baseOverrides(), - ), - ); - await tester.pumpAndSettle(); - - await tester.enterText( - find.byKey(const Key('importCodeField')), - 'not valid json', - ); - await tester.pumpAndSettle(); - - expect(find.textContaining('Invalid code'), findsOneWidget); - }); - - testWidgets('valid code shows account preview and enables import', ( + testWidgets('shows account selection when multiple accounts present', ( tester, ) async { - const account = Account( - id: 'acc-99', + const account1 = Account( + id: 'acc-1', + displayName: 'Alice', + email: 'alice@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ); + const account2 = Account( + id: 'acc-2', displayName: 'Bob', email: 'bob@example.com', imapHost: 'imap.example.com', smtpHost: 'smtp.example.com', ); - final code = jsonEncode({ - 'v': 1, - 'account': account.toJson(), - 'password': 'secret', - }); + + // Generate a real key pair and a valid pubkey QR string to feed in. + final material = await ShareEncryptionService.generateKeyPair(); + final pubKeyQr = ShareEncryptionService.encodePublicKeyQr( + material.keyId, + material.publicKeyBytes, + ); await tester.pumpWidget( buildApp( - initialLocation: '/accounts/import', - overrides: baseOverrides(), + initialLocation: '/accounts/send', + overrides: baseOverrides(accounts: [account1, account2]), ), ); await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const Key('importCodeField')), - code, - ); - await tester.pumpAndSettle(); + // On desktop the text fallback is shown — simulate pasting the pubkey. + final field = find.byKey(const Key('pubKeyInputField')); + if (field.evaluate().isNotEmpty) { + await tester.enterText(field, pubKeyQr); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); - expect(find.text('Bob'), findsOneWidget); - expect(find.text('bob@example.com'), findsOneWidget); - - final importBtn = tester.widget( - find.byKey(const Key('importButton')), - ); - expect(importBtn.onPressed, isNotNull); + // With two accounts the selection list should appear. + expect(find.byKey(const Key('sendSelectedButton')), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + } + // On mobile the MobileScanner handles this; we skip it in widget tests. }); - testWidgets('successful import navigates back to accounts list', ( + testWidgets('shows encrypted QR after single account auto-select', ( tester, ) async { const account = Account( - id: 'acc-99', - displayName: 'Bob', - email: 'bob@example.com', + id: 'acc-1', + displayName: 'Alice', + email: 'alice@example.com', imapHost: 'imap.example.com', smtpHost: 'smtp.example.com', ); - final code = jsonEncode({ - 'v': 1, - 'account': account.toJson(), - 'password': 'secret', - }); + + final material = await ShareEncryptionService.generateKeyPair(); + final pubKeyQr = ShareEncryptionService.encodePublicKeyQr( + material.keyId, + material.publicKeyBytes, + ); await tester.pumpWidget( buildApp( - initialLocation: '/accounts/import', - overrides: baseOverrides(), + initialLocation: '/accounts/send', + overrides: baseOverrides(accounts: [account]), ), ); await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const Key('importCodeField')), - code, - ); - await tester.pumpAndSettle(); + final field = find.byKey(const Key('pubKeyInputField')); + if (field.evaluate().isNotEmpty) { + await tester.enterText(field, pubKeyQr); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('importButton'))); - await tester.pumpAndSettle(); - - expect(find.text('SharedInbox'), findsOneWidget); + // Single account → auto-selected → encrypted QR shown immediately. + expect( + find.byKey(const Key('encryptedAccountsQrCode')), + findsOneWidget, + ); + expect(find.byKey(const Key('copyEncryptedButton')), findsOneWidget); + } }); }); } diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index 9a8374b..7333b22 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -136,7 +136,7 @@ void main() { expect(find.text('Add account'), findsOneWidget); }); - testWidgets('account popup menu contains Export account item', ( + testWidgets('account popup menu contains Send accounts item', ( tester, ) async { await tester.pumpWidget( @@ -150,7 +150,7 @@ void main() { await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); - expect(find.text('Export account'), findsOneWidget); + expect(find.text('Send accounts'), findsOneWidget); }); testWidgets('account popup menu contains Force full sync item', ( diff --git a/test/widget/add_account_screen_test.dart b/test/widget/add_account_screen_test.dart index b418ff6..6c34e6c 100644 --- a/test/widget/add_account_screen_test.dart +++ b/test/widget/add_account_screen_test.dart @@ -7,13 +7,14 @@ import 'helpers.dart'; void main() { group('AddAccountScreen', () { - testWidgets('step 1: shows Import account button', (tester) async { + testWidgets('step 1: shows Receive account button', (tester) async { await tester.pumpWidget( buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()), ); await tester.pumpAndSettle(); expect(find.byKey(const Key('importAccountButton')), findsOneWidget); + expect(find.text('Receive account'), findsOneWidget); }); testWidgets('step 1: shows email field and Continue button', ( diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 1e9e339..ff70375 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -18,13 +18,15 @@ import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart'; +import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; +import 'package:sharedinbox/core/services/share_encryption_service.dart'; import 'package:sharedinbox/di.dart'; -import 'package:sharedinbox/ui/screens/account_export_screen.dart'; -import 'package:sharedinbox/ui/screens/account_import_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; +import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; +import 'package:sharedinbox/ui/screens/account_send_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; import 'package:sharedinbox/ui/screens/compose_screen.dart'; @@ -74,6 +76,19 @@ class FakeAccountRepository implements AccountRepository { Future getPassword(String accountId) async => 'test-password'; } +class FakeShareKeyRepository implements ShareKeyRepository { + ShareKeyMaterial? _material; + + @override + Future createKeyPair() async { + _material = await ShareEncryptionService.generateKeyPair(); + return _material!; + } + + @override + Future findByKeyId(dynamic keyId) async => _material; +} + class FakeDraftRepository implements DraftRepository { int _nextId = 1; final Map _drafts = {}; @@ -375,8 +390,12 @@ Widget buildApp({ builder: (ctx, state) => const AddAccountScreen(), ), GoRoute( - path: 'import', - builder: (ctx, state) => const AccountImportScreen(), + path: 'receive', + builder: (ctx, state) => const AccountReceiveScreen(), + ), + GoRoute( + path: 'send', + builder: (ctx, state) => const AccountSendScreen(), ), GoRoute( path: ':accountId/edit', @@ -384,12 +403,6 @@ Widget buildApp({ accountId: state.pathParameters['accountId']!, ), ), - GoRoute( - path: ':accountId/export', - builder: (ctx, state) => AccountExportScreen( - accountId: state.pathParameters['accountId']!, - ), - ), GoRoute( path: ':accountId/search', builder: (ctx, state) => @@ -499,6 +512,7 @@ List baseOverrides({ connectionTestServiceProvider.overrideWithValue( FakeConnectionTestService(error: connectionError), ), + shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()), ]; // ---------------------------------------------------------------------------