fix(mobile): tolerate unrelated NIP-11 relay metadata (#8160)

Relay capability additions such as `read_state_snapshot` currently make
mobile push discovery reject otherwise valid NIP-11 documents. This
blocks descriptor-dependent enrollment, lease updates, and remote
revocation.

Read unrelated top-level metadata permissively while preserving
validation of the advertised `nip-pl` extension and the complete push
descriptor. Each returned descriptor value is covered by compatibility
tests; missing or malformed required push fields still fail.

### Validation

- [Codex code
review](https://github.com/block/buzz/pull/8160#issuecomment-6049355700)
and [security
review](https://github.com/block/buzz/pull/8160#issuecomment-6049486587)
completed without findings on the final code.
- Added 24 regression cases covering read-state metadata, arbitrary
future metadata, HTTP discovery, missing/malformed push fields, and
invalid extension advertisements. The six compatibility/discovery cases
fail on unchanged main and pass with the fix.
- All 55 focused descriptor, bootstrap, capability, and revocation tests
pass. Mobile formatting and analysis pass.
- Full mobile gate passes: 3,063 tests passed and four skipped with a
configured gateway, plus all three tests for an unconfigured gateway
build and the gateway recipe checks.
- [Current-head
CI](https://github.com/block/buzz/actions/runs/37706738403) passes,
including Flutter tests, Android debug APK compilation, the complete iOS
simulator app, and the unsigned iOS release build.
- Production HTTP descriptor discovery against live personal-relay
metadata passes without enrollment or relay changes.
- Device enrollment, delivery, notification tap, and opt-out remain to
be exercised in the updated iOS candidate. No relay or device state was
changed.




buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-07 17:37:02 -07:00
committed by GitHub
parent 1972b7d256
commit 70d2ca7760
2 changed files with 107 additions and 27 deletions

View File

@@ -44,33 +44,6 @@ class BuzzPushLeaseDescriptor {
factory BuzzPushLeaseDescriptor.fromRelayInformation(
Map<String, dynamic> information,
) {
_requireExactKeys(
information,
required: const {},
allowed: const {
'name',
'description',
'pubkey',
'contact',
'supported_nips',
'supported_extensions',
'software',
'version',
'limitation',
'retention',
'relay_countries',
'language_tags',
'tags',
'posting_policy',
'payments_url',
'fees',
'icon',
'self',
'pairing_relay_url',
'push',
},
name: 'NIP-11 document',
);
final extensions = _stringList(
information['supported_extensions'],
name: 'supported_extensions',

View File

@@ -11,6 +11,8 @@ import 'package:buzz/shared/relay/relay_socket.dart';
import 'package:buzz/shared/relay/signed_event_relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
@@ -298,6 +300,111 @@ void main() {
expect(jsonDecode(plaintext), jsonDecode(publication.plaintext));
});
group('NIP-11 forward compatibility', () {
final metadata = <String, Object?>{
'read_state_snapshot': {
'version': 1,
'community_id': '00000000-0000-4000-8000-000000000001',
'max_events': 4096,
'max_event_array_bytes': 8388608,
},
'future_object': {
'nested': [1, true, null],
},
'future_array': [1, 'two'],
'future_scalar': false,
'future_null': null,
};
for (final entry in metadata.entries) {
test('ignores unrelated top-level ${entry.key}', () {
final information = _descriptorJson(relay.public)
..[entry.key] = entry.value;
final parsed = BuzzPushLeaseDescriptor.fromRelayInformation(
information,
);
expect(parsed.origin, descriptor.origin);
expect(parsed.executorPubkey, descriptor.executorPubkey);
expect(parsed.executorKeyId, descriptor.executorKeyId);
expect(parsed.transport, descriptor.transport);
expect(parsed.maxLeaseTtlSeconds, descriptor.maxLeaseTtlSeconds);
expect(parsed.maxContentLength, descriptor.maxContentLength);
expect(parsed.maxPlaintextLength, descriptor.maxPlaintextLength);
expect(parsed.maxEndpointLength, descriptor.maxEndpointLength);
expect(parsed.maxStringLength, descriptor.maxStringLength);
});
}
test('HTTP discovery accepts unrelated metadata', () async {
final information = _descriptorJson(relay.public)..addAll(metadata);
final client = MockClient((request) async {
expect(request.url, Uri.parse('https://tenant.example:8443/'));
expect(request.headers['Accept'], 'application/nostr+json');
return http.Response(jsonEncode(information), 200);
});
addTearDown(client.close);
final parsed = await fetchBuzzPushLeaseDescriptor(
'https://tenant.example:8443',
client: client,
);
expect(parsed.executorPubkey, relay.public);
expect(parsed.origin, descriptor.origin);
});
for (final field in [
'origin',
'keys',
'app_profiles',
'push_kinds',
'h_grammar',
'class_support',
'limitation',
]) {
for (final missing in [true, false]) {
test(
'${missing ? 'missing' : 'malformed'} push.$field fails with future metadata',
() {
final information = _descriptorJson(relay.public)..addAll(metadata);
final push = information['push'] as Map<String, dynamic>;
if (missing) {
push.remove(field);
} else {
push[field] = false;
}
expect(
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
throwsA(
isA<FormatException>().having(
(error) => error.message,
'rejected field',
contains(field),
),
),
);
},
);
}
}
for (final extensions in [
null,
false,
<String>[],
['nip-er'],
]) {
test('invalid extension advertisement $extensions fails', () {
final information = _descriptorJson(relay.public)
..addAll(metadata)
..['supported_extensions'] = extensions;
expect(
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
throwsA(isA<FormatException>()),
);
});
}
});
test('descriptor rejects canonical origin with a trailing slash', () {
final information = _descriptorJson(relay.public);
(information['push'] as Map<String, dynamic>)['origin'] =