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:
co-authored by
Claude Sonnet 4.6
parent
c21d198a25
commit
04e65d2fba
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user