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
+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();
}