diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index 8452074671..5e4fc01553 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -17,8 +17,50 @@ import '../theme/community_theme_preference.dart'; class ReadStateCrypto { final Uint8List conversationKey; + final String Function(Uint8List conversationKey, String ciphertext) _decrypt; + final int _maxRetainedLength; - const ReadStateCrypto._(this.conversationKey); + /// Plaintext by ciphertext, least recently used first. NIP-44 payloads + /// carry a random nonce and a MAC, so under this one conversation key the + /// same ciphertext always decrypts to the same plaintext. Every publish + /// re-reads up to [readStateFetchLimit] of our own blobs, every resume + /// replays them on the live subscription, and the relay echoes each + /// publish back, so most decrypts here repeat a ciphertext this instance + /// already decrypted or produced. Decrypting is pure-Dart ChaCha20 and HMAC + /// on the UI isolate, so repeats are served from this memo. + final Map _plaintextByCiphertext = {}; + int _retainedLength = 0; + + /// Upper bound on the code units the memo retains, counting each ciphertext + /// key and its plaintext, so it cannot grow without limit however large the + /// blobs get (about 8 MB of one-byte strings at the default). Each slot's + /// current blob costs up to about 95,000 (a 40 KiB plaintext and its + /// base64 ciphertext), so this holds about 80 slots. The replay walks every + /// slot in turn, so a bound below the live slots' total evicts each entry + /// before its next use and nothing hits; an account with 27 slots measured + /// about 2.6M. + static const defaultMaxRetainedLength = 8000000; + + ReadStateCrypto._( + this.conversationKey, { + String Function(Uint8List conversationKey, String ciphertext) decrypt = + nip44Decrypt, + int maxRetainedLength = defaultMaxRetainedLength, + }) : _decrypt = decrypt, + _maxRetainedLength = maxRetainedLength; + + /// Builds a crypto around a known key with a counting or bounded memo. + @visibleForTesting + ReadStateCrypto.forTesting( + Uint8List conversationKey, { + String Function(Uint8List conversationKey, String ciphertext) decrypt = + nip44Decrypt, + int maxRetainedLength = defaultMaxRetainedLength, + }) : this._( + conversationKey, + decrypt: decrypt, + maxRetainedLength: maxRetainedLength, + ); static ReadStateCrypto? tryCreate({ required String nsec, @@ -36,10 +78,45 @@ class ReadStateCrypto { } } - String encrypt(String plaintext) => nip44Encrypt(conversationKey, plaintext); + String encrypt(String plaintext) { + final ciphertext = nip44Encrypt(conversationKey, plaintext); + // The relay echoes this blob back, and the next publish fetches it. + _remember(ciphertext, plaintext); + return ciphertext; + } - String decrypt(String ciphertext) => - nip44Decrypt(conversationKey, ciphertext); + /// Throws on a ciphertext that does not decrypt, every time: failures are + /// never memoized. + String decrypt(String ciphertext) { + final known = _plaintextByCiphertext.remove(ciphertext); + if (known != null) { + // Most recently used goes last, so superseded blobs are evicted first. + _plaintextByCiphertext[ciphertext] = known; + return known; + } + final plaintext = _decrypt(conversationKey, ciphertext); + _remember(ciphertext, plaintext); + return plaintext; + } + + void _remember(String ciphertext, String plaintext) { + final cost = ciphertext.length + plaintext.length; + if (cost > _maxRetainedLength) return; + final replaced = _plaintextByCiphertext.remove(ciphertext); + if (replaced != null) { + _retainedLength -= ciphertext.length + replaced.length; + } + _plaintextByCiphertext[ciphertext] = plaintext; + _retainedLength += cost; + while (_retainedLength > _maxRetainedLength) { + final oldest = _plaintextByCiphertext.keys.first; + final dropped = _plaintextByCiphertext.remove(oldest)!; + _retainedLength -= oldest.length + dropped.length; + } + } + + @visibleForTesting + int get retainedLength => _retainedLength; } enum _ApplyRemoteContextResult { unchanged, advanced } diff --git a/mobile/test/features/channels/read_state/read_state_crypto_memo_test.dart b/mobile/test/features/channels/read_state/read_state_crypto_memo_test.dart new file mode 100644 index 0000000000..e7770d8d4a --- /dev/null +++ b/mobile/test/features/channels/read_state/read_state_crypto_memo_test.dart @@ -0,0 +1,277 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/read_state/read_state_format.dart'; +import 'package:buzz/shared/read_state/read_state_manager.dart'; +import 'package:buzz/shared/read_state/read_state_storage.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +const _relayUrl = 'https://relay.example'; + +/// A crypto whose cipher work is counted: [decrypts] is how many times the +/// real NIP-44 decrypt ran. +class _CountingCrypto { + _CountingCrypto(nostr.Keys keys, {int? maxRetainedLength}) { + final key = _key(keys); + crypto = ReadStateCrypto.forTesting( + key, + decrypt: (conversationKey, ciphertext) { + decrypts++; + return nip44Decrypt(conversationKey, ciphertext); + }, + maxRetainedLength: + maxRetainedLength ?? ReadStateCrypto.defaultMaxRetainedLength, + ); + } + + late final ReadStateCrypto crypto; + int decrypts = 0; +} + +void main() { + late nostr.Keys keys; + setUp(() => keys = nostr.Keys.generate()); + + group('ReadStateCrypto memo', () { + test('decrypting the same ciphertext twice runs the cipher once', () { + final counting = _CountingCrypto(keys); + // Another device's blob: this instance never encrypted it. + final foreign = ReadStateCrypto.forTesting(_key(keys)).encrypt('{"a":1}'); + + expect(counting.crypto.decrypt(foreign), '{"a":1}'); + expect(counting.crypto.decrypt(foreign), '{"a":1}'); + expect(counting.decrypts, 1); + }); + + test('decrypting a ciphertext it produced runs the cipher zero times', () { + final counting = _CountingCrypto(keys); + final ciphertext = counting.crypto.encrypt('{"own":2}'); + + expect(counting.crypto.decrypt(ciphertext), '{"own":2}'); + expect(counting.decrypts, 0); + }); + + test('a failed decrypt throws every time and is not memoized', () { + final counting = _CountingCrypto(keys); + final ciphertext = counting.crypto.encrypt('{"own":2}'); + // Flip the last base64 characters: the MAC no longer matches. + final tampered = '${ciphertext.substring(0, ciphertext.length - 4)}AAAA'; + + expect(() => counting.crypto.decrypt(tampered), throwsA(anything)); + expect(() => counting.crypto.decrypt(tampered), throwsA(anything)); + expect(counting.decrypts, 2); + }); + + test('the bound evicts the oldest entries and caps retained memory', () { + const bound = 3000; + final counting = _CountingCrypto(keys, maxRetainedLength: bound); + final plain = 'x' * 400; + final ciphertexts = [ + for (var i = 0; i < 20; i++) counting.crypto.encrypt('$i$plain'), + ]; + + expect(counting.crypto.retainedLength, lessThanOrEqualTo(bound)); + expect(counting.crypto.retainedLength, greaterThan(0)); + + // Newest is retained, oldest was evicted and decrypts again. + counting.crypto.decrypt(ciphertexts.last); + expect(counting.decrypts, 0); + expect(counting.crypto.decrypt(ciphertexts.first), '0$plain'); + expect(counting.decrypts, 1); + expect(counting.crypto.retainedLength, lessThanOrEqualTo(bound)); + }); + + test('replaying 27 full slots twice decrypts each slot once', () { + // A measured account: 27 slots near the 40 KiB plaintext budget, all + // replayed on every resume. Their total (about 2.6M code units) is + // more than an oldest-first memo of 2M can hold, which made every + // replay miss. + final counting = _CountingCrypto(keys); + final otherDevice = ReadStateCrypto.forTesting(_key(keys)); + final slots = [ + for (var i = 0; i < 27; i++) + otherDevice.encrypt('$i${'z' * (readStatePlaintextBytes - 8)}'), + ]; + + for (var pass = 0; pass < 2; pass++) { + for (final ciphertext in slots) { + counting.crypto.decrypt(ciphertext); + } + } + expect(counting.decrypts, slots.length); + }); + + test('a hit keeps an entry ahead of unused ones for eviction', () { + const bound = 3000; + final counting = _CountingCrypto(keys, maxRetainedLength: bound); + final plain = 'x' * 400; + final live = counting.crypto.encrypt('live$plain'); + // Our own superseded blobs: each publish adds one and never reads it. + for (var i = 0; i < 20; i++) { + counting.crypto.encrypt('$i$plain'); + expect(counting.crypto.decrypt(live), 'live$plain'); + } + expect(counting.decrypts, 0); + }); + + test('a blob larger than the whole bound is not retained', () { + final counting = _CountingCrypto(keys, maxRetainedLength: 100); + final ciphertext = counting.crypto.encrypt('y' * 200); + + expect(counting.crypto.retainedLength, 0); + expect(counting.crypto.decrypt(ciphertext), 'y' * 200); + expect(counting.decrypts, 1); + }); + }); + + group('ReadStateManager decrypt work', () { + test('echo and later pre-publish fetches do not re-decrypt', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final counting = _CountingCrypto(keys); + final otherDevice = ReadStateCrypto.forTesting(_key(keys)); + final session = _CapturingRelaySession(); + final relay = _RecordingSignedEventRelay(); + final manager = ReadStateManager( + pubkey: keys.public, + relayUrl: _relayUrl, + prefs: prefs, + crypto: counting.crypto, + relaySession: session, + signedEventRelay: relay, + remoteEnabled: true, + onChanged: () {}, + ); + final slotId = ReadStateStorage( + prefs, + ).getOrCreateSlotId(keys.public, _relayUrl); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + NostrEvent event(String content, String id, int createdAt, String slot) => + NostrEvent( + id: id, + pubkey: keys.public, + createdAt: createdAt, + kind: EventKind.readState, + tags: [ + ['d', '$readStateDTagPrefix$slot'], + ['t', 'read-state'], + ], + content: content, + sig: 'sig', + ); + + // Three blobs from other devices, stored before we ever publish. + const foreignCount = 3; + session.history = [ + for (var i = 0; i < foreignCount; i++) + event( + otherDevice.encrypt( + jsonEncode( + ReadStateBlob( + clientId: 'device-$i', + contexts: {'channel-$i': 10 + i}, + ).toJson(), + ), + ), + 'foreign-event-$i', + now - 100 + i, + 'other-$i', + ), + ]; + await manager.initialize(); + expect(counting.decrypts, foreignCount); + + // Publish 1: its pre-fetch re-reads the foreign blobs. + manager.markContextRead('channel-a', now); + await manager.flush(); + expect(relay.contents, hasLength(1)); + expect(counting.decrypts, foreignCount); + + // The relay echoes our own blob on the live subscription. + final echo = event(relay.contents.last, 'own-event-1', now + 1, slotId); + session.onEvent!(echo); + expect(counting.decrypts, foreignCount, reason: 'echo of our own blob'); + + // Publish 2 and 3 pre-fetch the same events plus our own blobs. + session.history = [...session.history, echo]; + manager.markContextRead('channel-b', now); + await manager.flush(); + expect(relay.contents, hasLength(2)); + final echo2 = event(relay.contents.last, 'own-event-2', now + 2, slotId); + session.history = [...session.history, echo2]; + manager.markContextRead('channel-c', now); + await manager.flush(); + expect(relay.contents, hasLength(3)); + + expect( + counting.decrypts, + foreignCount, + reason: 'each ciphertext is decrypted at most once', + ); + manager.dispose(flushPending: false); + }); + }); +} + +class _CapturingRelaySession extends RelaySessionNotifier { + List history = []; + void Function(NostrEvent)? onEvent; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => history; + + @override + Future> fetchReadStateSnapshot({ + Duration timeout = const Duration(seconds: 8), + }) async => history; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + this.onEvent = onEvent; + return () {}; + } +} + +class _RecordingSignedEventRelay implements SignedEventRelay { + final List contents = []; + + @override + String? get pubkey => null; + + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + void Function(NostrEvent event)? onSigned, + }) async { + contents.add(content); + return NostrEvent( + id: 'ack-${contents.length}', + pubkey: '', + createdAt: createdAt ?? 0, + kind: kind, + tags: tags, + content: content, + sig: '', + ); + } +} + +Uint8List _key(nostr.Keys keys) => getConversationKey( + nostr.Nip19.decode(payload: keys.nsec).data, + keys.public, +);