Files
sharedinbox/lib/data/repositories/share_key_repository_impl.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 04e65d2fba 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>
2026-05-16 01:19:01 +02:00

68 lines
2.0 KiB
Dart

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