From 69ac7db19865517a0e41ef445499190a41044313 Mon Sep 17 00:00:00 2001 From: Other Brother Darryl Date: Thu, 13 Aug 2026 19:54:59 -0400 Subject: [PATCH] fix(mobile): harden section workspace cutover Verify legacy NIP-01 source events, strictly parse v1 legacy state, and stage projection UI updates until cache persistence succeeds. Signed-off-by: Other Brother Darryl --- .../channel_sections_manager.dart | 47 +++-- .../channel_sections_provider.dart | 8 +- .../section_workspace_sync.dart | 158 ++++++++++++++++- .../channel_sections_manager_test.dart | 71 ++++++-- .../section_workspace_sync_test.dart | 160 ++++++++++++++++++ 5 files changed, 409 insertions(+), 35 deletions(-) diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart index d34ba83a8..149f0d87b 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart @@ -89,8 +89,10 @@ class ChannelSectionsManager { ChannelSectionStore get store => _store; - bool applyWorkspaceProjection(SectionWorkspaceProjection projection) { - if (_disposed) return false; + ChannelSectionStore? stageWorkspaceProjection( + SectionWorkspaceProjection projection, + ) { + if (_disposed) return null; try { final key = SectionWorkspaceKeyEnvelopeCrypto( nsec: _workspaceNsec, @@ -128,26 +130,33 @@ class ChannelSectionsManager { ), ); } - // Workspace state is read-only in Stage 1. Do not overwrite the local - // legacy store when rendering a verified projection; it remains the - // rollback source used to construct an import until cutover completes. - _store = ChannelSectionStore( + return ChannelSectionStore( sections: sections, assignments: { for (final assignment in projection.assignments) assignment.channelId: assignment.sectionId, }, ); - _onChanged(); - return true; } catch (error) { debugPrint( '[ChannelSectionsManager] workspace projection rejected: $error', ); - return false; + return null; } } + bool commitWorkspaceProjection(ChannelSectionStore staged) { + if (_disposed) return false; + _store = staged; + _onChanged(); + return true; + } + + bool applyWorkspaceProjection(SectionWorkspaceProjection projection) { + final staged = stageWorkspaceProjection(projection); + return staged != null && commitWorkspaceProjection(staged); + } + String get _workspaceNsec => _workspaceSync?.nsec ?? ''; String get _workspaceAuthority => _workspaceSync?.relayAuthority ?? ''; @@ -542,13 +551,21 @@ class ChannelSectionsManager { void _mergeEvents(List events) { for (final event in events) { - if (event.pubkey != pubkey) continue; + if (event.pubkey != pubkey || + event.kind != EventKind.readState || + !verifySectionWorkspaceNostrEvent(event)) { + continue; + } _mergeEvent(event); } } void _mergeEvent(NostrEvent event) { - if (event.pubkey != pubkey || event.kind != EventKind.readState) return; + if (event.pubkey != pubkey || + event.kind != EventKind.readState || + !verifySectionWorkspaceNostrEvent(event)) { + return; + } final pendingSource = _workspaceSync?.isPendingImportSource(event.id) ?? false; if (_workspaceSync != null && @@ -580,9 +597,9 @@ class ChannelSectionsManager { try { final plaintext = _crypto.decrypt(event.content); final parsed = parseSectionWorkspaceJson(plaintext); - if (parsed is! Map) return; - - final incoming = ChannelSectionStore.fromJson(parsed); + final legacy = parseSectionWorkspaceLegacy(parsed); + if (legacy == null) return; + final incoming = legacy.store; if (_workspaceSync != null && _workspaceSync.probeCompleted && @@ -590,7 +607,7 @@ class ChannelSectionsManager { unawaited( _workspaceSync.tryImportLegacy( event: event, - plaintext: parsed, + plaintext: legacy.value, store: incoming, ), ); diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index 0cf799812..aaf2edbcd 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -78,9 +78,13 @@ class ChannelSectionsNotifier extends Notifier { if (_manager != manager) return; manager.scheduleWorkspaceRetry(); }, - onProjection: (projection) { + stageProjection: (projection) { + if (_manager != manager) return null; + return manager.stageWorkspaceProjection(projection); + }, + commitProjection: (staged) { if (_manager != manager) return false; - return manager.applyWorkspaceProjection(projection); + return manager.commitWorkspaceProjection(staged); }, ); manager = ChannelSectionsManager( diff --git a/mobile/lib/features/channels/channel_sections/section_workspace_sync.dart b/mobile/lib/features/channels/channel_sections/section_workspace_sync.dart index e15ab3eb8..2c34615f8 100644 --- a/mobile/lib/features/channels/channel_sections/section_workspace_sync.dart +++ b/mobile/lib/features/channels/channel_sections/section_workspace_sync.dart @@ -29,6 +29,35 @@ final _sectionWorkspaceUuid = RegExp( r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', ); final _sectionWorkspaceHex64 = RegExp(r'^[0-9a-f]{64}$'); +bool verifySectionWorkspaceNostrEvent(NostrEvent event) { + try { + final verifiedEvent = nostr.Event.fromJson(jsonEncode(event.toJson())); + return verifiedEvent.id == event.id && + verifiedEvent.pubkey == event.pubkey && + verifiedEvent.createdAt == event.createdAt && + verifiedEvent.kind == event.kind && + verifiedEvent.tags.length == event.tags.length && + _sameSectionWorkspaceTags(verifiedEvent.tags, event.tags) && + verifiedEvent.content == event.content && + verifiedEvent.sig == event.sig; + } catch (_) { + return false; + } +} + +bool _sameSectionWorkspaceTags( + List> left, + List> right, +) { + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index++) { + if (left[index].length != right[index].length) return false; + for (var tagIndex = 0; tagIndex < left[index].length; tagIndex++) { + if (left[index][tagIndex] != right[index][tagIndex]) return false; + } + } + return true; +} bool _validSectionWorkspaceUuid(Object? value) => value is String && @@ -497,6 +526,100 @@ SectionWorkspaceProjection? parseSectionWorkspaceProjectionJson(String json) { } } +class SectionWorkspaceLegacyParseResult { + final Map value; + final ChannelSectionStore store; + + const SectionWorkspaceLegacyParseResult({ + required this.value, + required this.store, + }); +} + +SectionWorkspaceLegacyParseResult? parseSectionWorkspaceLegacy(Object? value) { + if (value is! Map || + !_exactKeys(value, const ['version', 'sections', 'assignments'])) { + return null; + } + final version = value['version']; + final rawSections = value['sections']; + final rawAssignments = value['assignments']; + if (version is! int || + version != 1 || + rawSections is! List || + rawSections.length > sectionWorkspaceMaxSections || + rawAssignments is! Map || + rawAssignments.length > sectionWorkspaceMaxAssignments) { + return null; + } + + final ids = {}; + final orders = {}; + final sections = []; + for (final raw in rawSections) { + if (raw is! Map || + raw.keys.any( + (key) => !const {'id', 'name', 'icon', 'order'}.contains(key), + ) || + raw.length < 3) { + return null; + } + final id = raw['id']; + final name = raw['name']; + final icon = raw['icon']; + final order = raw['order']; + if (id is! String || + !_validSectionWorkspaceUuid(id) || + !ids.add(id) || + name is! String || + name.isEmpty || + utf8.encode(name).length > sectionWorkspaceMaxEncryptedMetadataBytes || + (icon != null && icon is! String) || + (icon is String && + utf8.encode(icon).length > + sectionWorkspaceMaxEncryptedMetadataBytes) || + order is! int || + order < 0 || + order >= rawSections.length || + !orders.add(order)) { + return null; + } + sections.add( + ChannelSection(id: id, name: name, icon: icon as String?, order: order), + ); + } + if (!List.generate( + sections.length, + (index) => index, + ).every(orders.contains)) { + return null; + } + + final assignments = {}; + for (final entry in rawAssignments.entries) { + final channelId = entry.key; + final sectionId = entry.value; + if (!_validSectionWorkspaceUuid(channelId) || + sectionId is! String || + !_validSectionWorkspaceUuid(sectionId) || + !ids.contains(sectionId)) { + return null; + } + assignments[channelId] = sectionId; + } + return SectionWorkspaceLegacyParseResult( + value: value, + store: ChannelSectionStore(sections: sections, assignments: assignments), + ); +} + +SectionWorkspaceLegacyParseResult? parseSectionWorkspaceLegacyJson( + String json, +) { + final value = parseSectionWorkspaceJson(json); + return parseSectionWorkspaceLegacy(value); +} + String sectionWorkspaceCanonicalJson(Object? value) { final out = StringBuffer(); void write(Object? current) { @@ -797,9 +920,18 @@ class SectionWorkspaceSyncManager { final RelaySessionNotifier? relaySession; final SignedEventRelay? signedEventRelay; final Future Function()? relaySelfProvider; + final Future Function( + SharedPreferences prefs, + String ownerPubkey, + String relayUrl, + SectionWorkspaceCache cache, + )? + cacheWriter; final String relayUrl; final String relayAuthority; - final bool Function(SectionWorkspaceProjection projection)? onProjection; + final ChannelSectionStore? Function(SectionWorkspaceProjection projection)? + stageProjection; + final bool Function(ChannelSectionStore staged)? commitProjection; final void Function()? onWorkspaceDiscovered; final void Function()? onSubscriptionLost; @@ -826,9 +958,11 @@ class SectionWorkspaceSyncManager { required this.relaySession, required this.signedEventRelay, this.relaySelfProvider, + this.cacheWriter, required String relayAuthority, this.nsec, - this.onProjection, + this.stageProjection, + this.commitProjection, this.onWorkspaceDiscovered, this.onSubscriptionLost, }) : relayUrl = relayAuthority, @@ -1102,8 +1236,7 @@ class SectionWorkspaceSyncManager { // Relay history/live delivery is transport-authenticated, but the // projection is cacheable data. Verify the NIP-01 event before treating // it as a last-verified projection. - final verifiedEvent = nostr.Event.fromJson(jsonEncode(event.toJson())); - if (verifiedEvent.id != event.id) return null; + if (!verifySectionWorkspaceNostrEvent(event)) return null; final projection = parseSectionWorkspaceProjectionJson(event.content); if (projection == null || projection.ownerPubkey != ownerPubkey) { return null; @@ -1141,12 +1274,13 @@ class SectionWorkspaceSyncManager { if (_cache != null && projection.revision <= _cache!.projection.revision) { return false; } - if (onProjection == null || !onProjection!(projection)) return false; + final staged = stageProjection?.call(projection); + if (staged == null) return false; final cache = SectionWorkspaceCache( eventId: event.id, projection: projection, ); - final persisted = await writeSectionWorkspaceCache( + final persisted = await (cacheWriter ?? writeSectionWorkspaceCache)( prefs, ownerPubkey, relayStorageScope, @@ -1156,6 +1290,7 @@ class SectionWorkspaceSyncManager { // Only a projection whose decrypted metadata and verified cache both made // it to durable local storage becomes authoritative. This keeps a cache // write failure on the compatibility side of the cutover boundary. + if (commitProjection?.call(staged) != true) return false; _cache = cache; _workspaceKnown = true; _cacheVerified = true; @@ -1219,6 +1354,7 @@ class SectionWorkspaceSyncManager { _importInFlight || event.pubkey != ownerPubkey || event.kind != EventKind.readState || + !verifySectionWorkspaceNostrEvent(event) || event.getTagValue('d') != 'channel-sections' || event.tags .where( @@ -1234,7 +1370,9 @@ class SectionWorkspaceSyncManager { } _importInFlight = true; try { - final canonical = sectionWorkspaceCanonicalJson(plaintext); + final acceptedLegacy = parseSectionWorkspaceLegacy(plaintext); + if (acceptedLegacy == null) return; + final canonical = sectionWorkspaceCanonicalJson(acceptedLegacy.value); final sourceHash = sectionWorkspaceSha256Hex(canonical); final hasPendingImport = _pendingImportSourceEvent != null || @@ -1254,7 +1392,11 @@ class SectionWorkspaceSyncManager { // The complete command is the durable cutover intent. Do not persist an // action-only marker: without the exact encrypted bytes there is // nothing safe to replay after restart. - final body = await _buildImportBody(event, sourceHash, store); + final body = await _buildImportBody( + event, + sourceHash, + acceptedLegacy.store, + ); if (body == null || _destroyed || _workspaceKnown) return; if (parseSectionWorkspaceImport(body) == null) return; final canonicalBody = sectionWorkspaceCanonicalJson(body); diff --git a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart index 16d1936d3..699bce065 100644 --- a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart +++ b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart @@ -23,25 +23,23 @@ void main() { required List> sections, Map assignments = const {}, required int createdAt, - String id = 'remote-event', }) { final payload = jsonEncode({ 'version': 1, 'sections': sections, 'assignments': assignments, }); - return NostrEvent( - id: id, - pubkey: keychain.public, - createdAt: createdAt, + final event = nostr.Event.from( kind: EventKind.readState, + content: crypto.encrypt(payload), tags: const [ ['d', 'channel-sections'], ['t', 'channel-sections'], ], - content: crypto.encrypt(payload), - sig: 'sig', + secretKey: keychain.secret, + createdAt: createdAt, ); + return NostrEvent.fromJson(event.toMap()); } ChannelSectionsManager buildManager({ @@ -60,6 +58,47 @@ void main() { ); } + test('rejects forged legacy event identity before merge', () async { + await setUpEnv(); + final valid = sectionsEvent( + sections: [ + { + 'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'name': 'Desktop Group', + 'order': 0, + }, + ], + createdAt: 100, + ); + final forged = NostrEvent( + id: 'f' * 64, + pubkey: valid.pubkey, + createdAt: valid.createdAt, + kind: valid.kind, + tags: valid.tags, + content: valid.content, + sig: valid.sig, + ); + final forgedSignature = NostrEvent( + id: valid.id, + pubkey: valid.pubkey, + createdAt: valid.createdAt, + kind: valid.kind, + tags: valid.tags, + content: valid.content, + sig: '0' * 128, + ); + final relay = _RateLimitedRelaySession( + failuresBeforeSuccess: 0, + historyEvents: [forged, forgedSignature], + ); + final manager = buildManager(relaySession: relay); + await manager.initialize(); + await Future.delayed(const Duration(milliseconds: 30)); + expect(manager.store.sections, isEmpty); + manager.dispose(flushPending: false); + }); + test('startup fetch rejected by relay rate limit retries until the remote ' 'blob is adopted (cold-start regression)', () async { await setUpEnv(); @@ -72,7 +111,11 @@ void main() { historyEvents: [ sectionsEvent( sections: [ - {'id': 's1', 'name': 'Desktop Group', 'order': 0}, + { + 'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'name': 'Desktop Group', + 'order': 0, + }, ], createdAt: 100, ), @@ -112,7 +155,11 @@ void main() { historyEvents: [ sectionsEvent( sections: [ - {'id': 's1', 'name': 'Desktop Group', 'order': 0}, + { + 'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'name': 'Desktop Group', + 'order': 0, + }, ], createdAt: 100, ), @@ -172,7 +219,11 @@ void main() { relay.emit( sectionsEvent( sections: [ - {'id': 's1', 'name': 'Desktop Group', 'order': 0}, + { + 'id': 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'name': 'Desktop Group', + 'order': 0, + }, ], createdAt: 100, ), diff --git a/mobile/test/features/channels/channel_sections/section_workspace_sync_test.dart b/mobile/test/features/channels/channel_sections/section_workspace_sync_test.dart index 96b254070..d5f8e87a4 100644 --- a/mobile/test/features/channels/channel_sections/section_workspace_sync_test.dart +++ b/mobile/test/features/channels/channel_sections/section_workspace_sync_test.dart @@ -2,10 +2,26 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:buzz/features/channels/channel_sections/channel_sections_manager.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; import 'package:buzz/features/channels/channel_sections/section_workspace_sync.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'; +class _WorkspaceHistoryRelaySession extends RelaySessionNotifier { + _WorkspaceHistoryRelaySession(this.events); + + final List events; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => events; +} + void main() { const owner = '1111111111111111111111111111111111111111111111111111111111111111'; @@ -250,6 +266,36 @@ void main() { }, ); + test('legacy parser rejects unknown, version, and malformed documents', () { + final valid = { + 'version': 1, + 'sections': [ + {'id': sectionA, 'name': 'Alpha', 'icon': null, 'order': 0}, + ], + 'assignments': {channel: sectionA}, + }; + expect(parseSectionWorkspaceLegacy(valid), isNotNull); + + expect(parseSectionWorkspaceLegacy({...valid, 'extra': true}), isNull); + expect(parseSectionWorkspaceLegacy({...valid, 'version': 2}), isNull); + expect( + parseSectionWorkspaceLegacy({ + ...valid, + 'sections': [ + {'id': sectionA, 'name': 'Alpha', 'icon': null, 'order': 1}, + ], + }), + isNull, + ); + expect( + parseSectionWorkspaceLegacy({ + ...valid, + 'assignments': {channel: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'}, + }), + isNull, + ); + }); + test('canonicalization matches the shared legacy plaintext vector', () { final input = { 'version': 1, @@ -311,6 +357,120 @@ void main() { ); }); + test( + 'cache write failure leaves staged projection and legacy state untouched', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final ownerKeys = nostr.Keys.generate(); + final relayKeys = nostr.Keys.generate(); + final sectionId = sectionA; + final oldStore = { + 'version': 1, + 'sections': [ + {'id': sectionId, 'name': 'Legacy', 'order': 0}, + ], + 'assignments': {}, + }; + await prefs.setString( + channelSectionsKey(ownerKeys.public), + jsonEncode(oldStore), + ); + final workspaceKey = Uint8List.fromList( + List.generate(32, (index) => index), + ); + final envelope = SectionWorkspaceKeyEnvelopeCrypto( + nsec: ownerKeys.nsec, + ownerPubkey: ownerKeys.public, + ).wrap(workspaceKey); + final metadata = SectionWorkspaceMetadataCrypto(workspaceKey); + final projectionValue = { + 'version': 1, + 'owner_pubkey': ownerKeys.public, + 'revision': 1, + 'layout_revision': 1, + 'key_epoch': 1, + 'migration': { + 'source_event_id': sourceEvent, + 'source_hash': sourceHash, + }, + 'reader_key_envelope': envelope, + 'sections': [ + { + 'id': sectionId, + 'rank': 0, + 'encrypted_label': metadata.encrypt( + plaintext: 'Projected', + community: 'relay.example', + ownerPubkey: ownerKeys.public, + sectionId: sectionId, + keyEpoch: 1, + purpose: 'label', + ), + 'encrypted_icon': null, + }, + ], + 'assignments': [], + }; + final relayEvent = nostr.Event.from( + kind: sectionWorkspaceProjectionKind, + content: jsonEncode(projectionValue), + tags: [ + ['d', ownerKeys.public], + ['p', ownerKeys.public], + ], + secretKey: relayKeys.secret, + createdAt: 1700000000, + ); + final relaySession = _WorkspaceHistoryRelaySession([ + NostrEvent.fromJson(relayEvent.toMap()), + ]); + var renders = 0; + late final ChannelSectionsManager manager; + final sync = SectionWorkspaceSyncManager( + ownerPubkey: ownerKeys.public, + nsec: ownerKeys.nsec, + prefs: prefs, + relaySession: relaySession, + signedEventRelay: null, + relaySelfProvider: () async => relayKeys.public, + relayAuthority: 'wss://relay.example', + stageProjection: (projection) => + manager.stageWorkspaceProjection(projection), + commitProjection: (staged) { + renders++; + return manager.commitWorkspaceProjection(staged); + }, + cacheWriter: (_, _, _, _) async => false, + ); + manager = ChannelSectionsManager( + pubkey: ownerKeys.public, + prefs: prefs, + crypto: ChannelSectionsCrypto(ownerKeys.nsec, ownerKeys.public), + relaySession: null, + signedEventRelay: null, + remoteEnabled: false, + workspaceSync: sync, + onChanged: () => renders++, + ); + + expect(await sync.probe(), isFalse); + expect(manager.store.sections.single.name, 'Legacy'); + expect(renders, 0); + expect(sync.cache, isNull); + expect( + prefs.getString( + channelSectionsKey( + ownerKeys.public, + relayAuthority: sync.relayStorageScope, + ), + ), + jsonEncode(oldStore), + ); + manager.dispose(flushPending: false); + }, + ); + test('cache persists the last verified projection and key epoch', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance();