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 <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1>
This commit is contained in:
Other Brother Darryl
2026-08-13 19:54:59 -04:00
parent a5d665a9cb
commit 69ac7db198
5 changed files with 409 additions and 35 deletions
@@ -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<NostrEvent> 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<String, dynamic>) 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,
),
);
@@ -78,9 +78,13 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
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(
@@ -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<List<String>> left,
List<List<String>> 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<String, dynamic> value;
final ChannelSectionStore store;
const SectionWorkspaceLegacyParseResult({
required this.value,
required this.store,
});
}
SectionWorkspaceLegacyParseResult? parseSectionWorkspaceLegacy(Object? value) {
if (value is! Map<String, dynamic> ||
!_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<String, dynamic> ||
rawAssignments.length > sectionWorkspaceMaxAssignments) {
return null;
}
final ids = <String>{};
final orders = <int>{};
final sections = <ChannelSection>[];
for (final raw in rawSections) {
if (raw is! Map<String, dynamic> ||
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 = <String, String>{};
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<String?> Function()? relaySelfProvider;
final Future<bool> 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);
@@ -23,25 +23,23 @@ void main() {
required List<Map<String, dynamic>> sections,
Map<String, String> 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<void>.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,
),
@@ -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<NostrEvent> events;
@override
Future<List<NostrEvent>> 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': <String, String>{},
};
await prefs.setString(
channelSectionsKey(ownerKeys.public),
jsonEncode(oldStore),
);
final workspaceKey = Uint8List.fromList(
List<int>.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();