feat: secure account sharing via public-key encryption (#107)

Replace the insecure plaintext QR export/import flow with an
end-to-end-encrypted account-transfer mechanism:

- Receiver generates an ephemeral X25519 key pair (20-minute lifetime,
  stored in the new share_keys DB table at schema v31) and displays it
  as a QR code (sharedinbox.de:pubkey:v1:…).
- Sender scans the public-key QR, selects accounts (or auto-selects
  when only one exists), encrypts them with ECIES (X25519-ECDH +
  HKDF-SHA256 + AES-256-GCM) and displays an encrypted QR
  (sharedinbox.de:encrypted-accounts:v1:…).
- Receiver scans the encrypted QR, decrypts, verifies the 20-minute
  expiry and MAC authentication tag, then imports the accounts.

New screens: AccountReceiveScreen (/accounts/receive) and
AccountSendScreen (/accounts/send), accessible from the account-list
drawer and per-account popup menu respectively.

Remove the old insecure AccountExportScreen and AccountImportScreen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-16 01:19:01 +02:00
co-authored by Claude Sonnet 4.6
parent c21d198a25
commit 04e65d2fba
21 changed files with 1543 additions and 430 deletions
+2
View File
@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@@ -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<ShareKeyMaterial> createKeyPair();
/// Returns the key pair whose ID matches [keyId], or null if not found /
/// expired.
Future<ShareKeyMaterial?> findByKeyId(Uint8List keyId);
}
@@ -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<String, dynamic> 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<ShareKeyMaterial> 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:<base64(keyId[16] || pubKey[32])>`
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<String> encryptAccounts({
required Uint8List recipientKeyId,
required Uint8List recipientPublicKeyBytes,
required List<AccountPayload> 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<List<AccountPayload>> 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<String, dynamic> json;
try {
json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
} 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<dynamic>;
return rawAccounts.map((entry) {
final m = entry as Map<String, dynamic>;
return AccountPayload(
accountJson: m['account'] as Map<String, dynamic>,
password: m['password'] as String,
);
}).toList();
}
}
+24 -1
View File
@@ -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<Column> 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<void> _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);
}
},
);
}
@@ -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<ShareKeyMaterial> 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<ShareKeyMaterial?> 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<void> _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();
}
+6
View File
@@ -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<AccountRepository>((ref) {
);
});
final shareKeyRepositoryProvider = Provider<ShareKeyRepository>((ref) {
return ShareKeyRepositoryImpl(ref.watch(dbProvider));
});
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
return MailboxRepositoryImpl(
ref.watch(dbProvider),
+8 -10
View File
@@ -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) =>
-129
View File
@@ -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<AccountExportScreen> createState() =>
_AccountExportScreenState();
}
class _AccountExportScreenState extends ConsumerState<AccountExportScreen> {
bool _loading = true;
String? _exportCode;
String? _error;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _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),
),
],
),
);
}
}
-172
View File
@@ -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<AccountImportScreen> createState() =>
_AccountImportScreenState();
}
class _AccountImportScreenState extends ConsumerState<AccountImportScreen> {
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<String, dynamic>;
if ((json['v'] as int?) != 1) {
throw const FormatException('Unknown version');
}
final account = Account.fromJson(
json['account'] as Map<String, dynamic>,
);
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<void> _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'),
),
],
),
),
);
}
}
+13 -5
View File
@@ -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<bool>(
@@ -398,7 +406,7 @@ enum _AccountAction {
edit,
emailFiltersRemote,
emailFiltersLocal,
export,
send,
delete,
}
+387
View File
@@ -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<AccountReceiveScreen> createState() =>
_AccountReceiveScreenState();
}
enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
_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<void> _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<void> _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]),
),
],
);
}
}
+351
View File
@@ -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<AccountSendScreen> createState() => _AccountSendScreenState();
}
enum _Step { scanning, selectAccounts, showEncrypted, error }
class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
_Step _step = _Step.scanning;
// Set after scanning the pubkey QR.
Uint8List? _recipientKeyId;
Uint8List? _recipientPublicKey;
// All available accounts + the selection (for step 2).
List<Account> _accounts = [];
final Set<String> _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<void> _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<void> _encryptAndShow() async {
final repo = ref.read(accountRepositoryProvider);
final selected = _accounts.where((a) => _selectedIds.contains(a.id));
final payloads = <AccountPayload>[];
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;
+2 -2
View File
@@ -299,8 +299,8 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
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'),
),
],
),
+6
View File
@@ -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
+4 -2
View File
@@ -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',
+3 -2
View File
@@ -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
]),
);
@@ -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<FormatException>()),
);
});
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<FormatException>()),
);
});
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),
);
});
});
}
+87 -94
View File
@@ -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<FilledButton>(
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<FilledButton>(
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);
}
});
});
}
+2 -2
View File
@@ -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', (
+2 -1
View File
@@ -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', (
+24 -10
View File
@@ -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<String> getPassword(String accountId) async => 'test-password';
}
class FakeShareKeyRepository implements ShareKeyRepository {
ShareKeyMaterial? _material;
@override
Future<ShareKeyMaterial> createKeyPair() async {
_material = await ShareEncryptionService.generateKeyPair();
return _material!;
}
@override
Future<ShareKeyMaterial?> findByKeyId(dynamic keyId) async => _material;
}
class FakeDraftRepository implements DraftRepository {
int _nextId = 1;
final Map<int, SavedDraft> _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<Override> baseOverrides({
connectionTestServiceProvider.overrideWithValue(
FakeConnectionTestService(error: connectionError),
),
shareKeyRepositoryProvider.overrideWithValue(FakeShareKeyRepository()),
];
// ---------------------------------------------------------------------------