diff --git a/mobile/lib/shared/relay/relay_auth_discovery.dart b/mobile/lib/shared/relay/relay_auth_discovery.dart new file mode 100644 index 00000000000..eac5bb8157d --- /dev/null +++ b/mobile/lib/shared/relay/relay_auth_discovery.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Host policy, independent of whether a relay supports optional NIP-FI. +enum RelayAuthRequirement { legacy, optional, required } + +/// Protocol compatibility is separate from host policy. It does not establish +/// a trusted sign-in origin or prove this build is configured for the host. +typedef RelayAuthDiscovery = ({ + RelayAuthRequirement requirement, + bool compatible, +}); + +/// Reads explicit policy even when this client cannot use the auth descriptor. +/// Missing fields retain legacy behavior; malformed policy remains a failure. +RelayAuthDiscovery parseRelayAuthDiscovery(Object? info) { + if (info is! Map) throw const FormatException('NIP-11 is not an object'); + final RelayAuthRequirement requirement; + if (!info.containsKey('requires_nip_fi')) { + requirement = RelayAuthRequirement.legacy; + } else { + final required = info['requires_nip_fi']; + if (required is! bool) { + throw const FormatException('Invalid NIP-FI requirement'); + } + requirement = required + ? RelayAuthRequirement.required + : RelayAuthRequirement.optional; + } + final limitation = info['limitation']; + final federation = info['federated_identity']; + final freshness = federation is Map + ? federation['assertion_freshness'] + : null; + return ( + requirement: requirement, + compatible: + limitation is Map && + limitation['federated_identity'] == true && + federation is Map && + federation['core'] == 'client-attached' && + freshness is Map && + freshness['class'] == 'offline-jwt', + ); +} + +/// A failed discovery request is distinct from a successfully discovered policy. +class RelayAuthDiscoveryFailure implements Exception { + const RelayAuthDiscoveryFailure(this.status); + + final int status; + + @override + String toString() => + 'Community authentication discovery failed (HTTP $status)'; +} + +/// Reads this host's public policy without credentials, redirects or caching. +/// The caller owns [client]; one deadline bounds headers and the entire body. +Future fetchRelayAuthDiscovery( + http.Client client, + Uri uri, { + Duration deadline = const Duration(seconds: 10), +}) async { + if (!['http', 'https'].contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + !['', '/', '/info'].contains(uri.path)) { + throw const FormatException('Expected a public community discovery URL'); + } + final abort = Completer(); + final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future) + ..followRedirects = false + ..headers['Accept'] = 'application/nostr+json'; + StreamIterator>? body; + Future read() async { + final response = await client.send(request); + final iterator = body = StreamIterator(response.stream); + if (response.statusCode != 200) { + throw RelayAuthDiscoveryFailure(response.statusCode); + } + final bytes = []; + while (await iterator.moveNext()) { + if (bytes.length + iterator.current.length > 256 * 1024) { + throw const FormatException('NIP-11 exceeds size limit'); + } + bytes.addAll(iterator.current); + } + return parseRelayAuthDiscovery(jsonDecode(utf8.decode(bytes))); + } + + try { + return await read().timeout(deadline); + } finally { + if (!abort.isCompleted) abort.complete(); + unawaited(body?.cancel()); + } +} diff --git a/mobile/test/fixtures/nip_fi/README.md b/mobile/test/fixtures/nip_fi/README.md new file mode 100644 index 00000000000..1b5bd2e2239 --- /dev/null +++ b/mobile/test/fixtures/nip_fi/README.md @@ -0,0 +1,69 @@ +# Community authentication discovery fixtures + +These are complete `/info` response bodies emitted by the production HTTP router +at [BUZZ-511 commit d3ae4935b11e207f0736833cd5220f207be359fd](https://github.com/block/buzz/commit/d3ae4935b11e207f0736833cd5220f207be359fd). +The normative field is documented in that commit's +[NIP-FI Discovery section](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/docs/nips/NIP-FI.md#discovery). + +| Fixture suffix | Global mode | Synthetic Host | Required | Protocol compatible | +| --- | --- | --- | --- | --- | +| `required` | off | `required.example` | true | true | +| `optional` | off | `free.example` | false | false | +| `optional_capable` | shadow | `free.example` | false | true | + +The export reused `nip_fi_policy::tests::fixture` from that commit and called +`router::build_router` through `tower::ServiceExt::oneshot` for both `/` and +`/info`, with `Host` and `Accept: application/nostr+json`. It asserted HTTP 200, +`Cache-Control: no-store`, and equal decoded documents for the two routes, then +saved the unmodified `/info` body. Policy and verifier inputs are synthetic test +boundaries; discovery serialization and routing are the production implementation. +This is a wire fixture, not deployed-host or device acceptance evidence. + +To regenerate, use a detached checkout of the source commit and append this +temporary test to `crates/buzz-relay/src/nip_fi_policy/tests.rs`: + +```rust +#[tokio::test] +async fn export_buzz_517_discovery_fixtures() { + let out = std::path::PathBuf::from(std::env::var("BUZZ_FIXTURE_OUT").unwrap()); + std::fs::create_dir_all(&out).unwrap(); + for (name, mode, host) in [ + ("required", NipFiMode::Off, "required.example"), + ("optional", NipFiMode::Off, "free.example"), + ("optional_capable", NipFiMode::Shadow, "free.example"), + ] { + let (state, _, _, _) = fixture(mode).await; + let app = crate::router::build_router(state); + let mut documents = Vec::new(); + for path in ["/", "/info"] { + let response = app.clone().oneshot( + Request::get(path) + .header(header::HOST, host) + .header(header::ACCEPT, "application/nostr+json") + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let bytes = to_bytes(response.into_body(), 256 * 1024).await.unwrap(); + documents.push(serde_json::from_slice::(&bytes).unwrap()); + if path == "/info" { + std::fs::write(out.join(format!("{name}.json")), &bytes).unwrap(); + } + } + assert_eq!(documents[0], documents[1]); + } +} +``` + +From that checkout, run with an absolute output directory: + +```sh +BUZZ_FIXTURE_OUT=/tmp/buzz-517-discovery cargo test -p buzz-relay --lib nip_fi_policy::tests::export_buzz_517_discovery_fixtures -- --exact +``` + +Copy the three output JSON bodies here with the source-commit filename prefix. +The temporary exporter does not modify the relay implementation. + +Legacy relays lacking `requires_nip_fi` remain covered separately by the parser's +absent-field cases. Capability alone must never turn an optional host into a +required one. These fixtures do not configure or authorize any sign-in origin. diff --git a/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json b/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json new file mode 100644 index 00000000000..f021147e665 --- /dev/null +++ b/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_optional.json @@ -0,0 +1 @@ +{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"due_delivery_mode":"push","max_not_before_delta":31536000}} \ No newline at end of file diff --git a/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json b/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json new file mode 100644 index 00000000000..4349afea6c5 --- /dev/null +++ b/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_optional_capable.json @@ -0,0 +1 @@ +{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"federated_identity":true,"due_delivery_mode":"push","max_not_before_delta":31536000},"federated_identity":{"assertion_freshness":{"class":"offline-jwt","maximum_residual_upstream_revocation_seconds":null},"core":"client-attached"}} \ No newline at end of file diff --git a/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_required.json b/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_required.json new file mode 100644 index 00000000000..9c564a99f05 --- /dev/null +++ b/mobile/test/fixtures/nip_fi/buzz_511_d3ae4935b_required.json @@ -0,0 +1 @@ +{"requires_nip_fi":true,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"federated_identity":true,"due_delivery_mode":"push","max_not_before_delta":31536000},"federated_identity":{"assertion_freshness":{"class":"offline-jwt","maximum_residual_upstream_revocation_seconds":null},"core":"client-attached"}} \ No newline at end of file diff --git a/mobile/test/shared/relay/relay_auth_discovery_test.dart b/mobile/test/shared/relay/relay_auth_discovery_test.dart new file mode 100644 index 00000000000..5c225abb7c0 --- /dev/null +++ b/mobile/test/shared/relay/relay_auth_discovery_test.dart @@ -0,0 +1,260 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/relay/relay_auth_discovery.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + const capable = { + 'limitation': {'federated_identity': true}, + 'federated_identity': { + 'core': 'client-attached', + 'assertion_freshness': {'class': 'offline-jwt'}, + }, + }; + + test('reads the committed BUZZ-511 router discovery fixtures', () async { + for (final (name, requirement, compatible) in [ + ('required', RelayAuthRequirement.required, true), + ('optional', RelayAuthRequirement.optional, false), + ('optional_capable', RelayAuthRequirement.optional, true), + ]) { + // Provenance and generator: test/fixtures/nip_fi/README.md. + final bytes = await File( + 'test/fixtures/nip_fi/buzz_511_d3ae4935b_$name.json', + ).readAsBytes(); + final client = MockClient((_) async => http.Response.bytes(bytes, 200)); + addTearDown(client.close); + expect( + await fetchRelayAuthDiscovery( + client, + Uri.parse('https://community.example/info'), + ), + (requirement: requirement, compatible: compatible), + ); + } + }); + + test('capability alone never establishes a host requirement', () { + for (final info in [{}, capable]) { + expect( + parseRelayAuthDiscovery(info).requirement, + RelayAuthRequirement.legacy, + ); + expect( + parseRelayAuthDiscovery({ + ...info, + 'requires_nip_fi': false, + }).requirement, + RelayAuthRequirement.optional, + ); + } + expect( + parseRelayAuthDiscovery({ + ...capable, + 'requires_nip_fi': true, + }).requirement, + RelayAuthRequirement.required, + ); + for (final info in [ + null, + [], + {'requires_nip_fi': null}, + {'requires_nip_fi': 'true'}, + {'requires_nip_fi': 1}, + ]) { + expect(() => parseRelayAuthDiscovery(info), throwsFormatException); + } + }); + + test('required policy survives unsupported or absent descriptors', () { + for (final (descriptor, compatible) in [ + (capable, true), + ({}, false), + ({...capable, 'limitation': null}, false), + ( + { + ...capable, + 'limitation': {'federated_identity': false}, + }, + false, + ), + ({...capable, 'federated_identity': null}, false), + for (final freshness in ['current-status', 'unknown-class', null]) + ( + { + ...capable, + 'federated_identity': { + 'core': 'client-attached', + 'assertion_freshness': {'class': freshness}, + }, + }, + false, + ), + ( + { + ...capable, + 'federated_identity': { + 'core': 'another-protocol', + 'assertion_freshness': {'class': 'offline-jwt'}, + }, + }, + false, + ), + ]) { + expect( + parseRelayAuthDiscovery({...descriptor, 'requires_nip_fi': true}), + (requirement: RelayAuthRequirement.required, compatible: compatible), + ); + } + }); + + test( + 'a new fetch observes this host upgrading without leaking credentials', + () async { + var upgraded = false; + final client = MockClient((request) async { + expect(request.headers['Accept'], 'application/nostr+json'); + expect(request.headers.containsKey('Authorization'), isFalse); + expect( + request.headers.containsKey('Nostr-Federated-Identity'), + isFalse, + ); + expect(request.followRedirects, isFalse); + return http.Response( + jsonEncode({ + ...capable, + 'requires_nip_fi': + request.url.host == 'required.example' && upgraded, + }), + 200, + ); + }); + addTearDown(client.close); + final target = Uri.parse('https://required.example/info'); + expect( + (await fetchRelayAuthDiscovery(client, target)).requirement, + RelayAuthRequirement.optional, + ); + upgraded = true; + expect( + (await fetchRelayAuthDiscovery(client, target)).requirement, + RelayAuthRequirement.required, + ); + expect( + (await fetchRelayAuthDiscovery( + client, + Uri.parse('https://free.example/info'), + )).requirement, + RelayAuthRequirement.optional, + ); + }, + ); + + test( + 'HTTP, network and malformed responses cannot become upgrade verdicts', + () async { + for (final status in [302, 401, 403, 404, 503]) { + final client = MockClient((_) async => http.Response('', status)); + addTearDown(client.close); + await expectLater( + fetchRelayAuthDiscovery( + client, + Uri.parse('https://community.example/info'), + ), + throwsA( + isA().having( + (error) => error.status, + 'status', + status, + ), + ), + ); + } + final unavailable = MockClient( + (_) async => throw http.ClientException('offline'), + ); + addTearDown(unavailable.close); + await expectLater( + fetchRelayAuthDiscovery( + unavailable, + Uri.parse('https://community.example/info'), + ), + throwsA(isA()), + ); + for (final body in [ + 'invalid', + '[]', + '{"requires_nip_fi":null}', + jsonEncode({ + ...capable, + 'requires_nip_fi': true, + 'padding': 'a' * (256 * 1024), + }), + ]) { + final client = MockClient((_) async => http.Response(body, 200)); + addTearDown(client.close); + await expectLater( + fetchRelayAuthDiscovery( + client, + Uri.parse('https://community.example/info'), + ), + throwsFormatException, + ); + } + }, + ); + + test('a stalled body times out and aborts its request', () async { + final client = _StalledDiscoveryClient(); + addTearDown(client.close); + await expectLater( + fetchRelayAuthDiscovery( + client, + Uri.parse('https://community.example/info'), + deadline: Duration.zero, + ), + throwsA(isA()), + ); + await client.aborted; + }); + + test('discovery refuses credential-bearing or non-discovery URLs', () async { + final client = MockClient((_) async { + fail('Invalid discovery URL reached the network'); + }); + addTearDown(client.close); + for (final url in [ + 'https://user:password@community.example/info', + 'https://community.example/info?token=secret', + 'https://community.example/info#secret', + 'https://community.example/query', + 'wss://community.example', + ]) { + await expectLater( + fetchRelayAuthDiscovery(client, Uri.parse(url)), + throwsFormatException, + ); + } + }); +} + +class _StalledDiscoveryClient extends http.BaseClient { + late final Future aborted; + final body = StreamController>(); + + @override + Future send(http.BaseRequest request) async { + aborted = (request as http.AbortableRequest).abortTrigger!; + return http.StreamedResponse(body.stream, 200); + } + + @override + void close() { + unawaited(body.close()); + super.close(); + } +}