diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index 4f5cea8aa5..13d18cb10f 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -281,7 +281,9 @@ fn validate_filter( for value in h.into_iter().flatten() { check_string(value, limits.max_string_len)?; let uuid = uuid::Uuid::parse_str(value).map_err(|_| "invalid h tag".to_string())?; - if uuid.get_version_num() != 4 || uuid.to_string() != *value { + // Channels include UUIDv5 starter IDs; only canonical text matters + // for exact event-tag matching, not the UUID version or variant. + if uuid.to_string() != *value { return Err("invalid h tag".into()); } } @@ -701,6 +703,60 @@ mod tests { ); } + #[test] + fn channel_filters_accept_starter_uuids_in_positive_and_ignore_filters() { + let starter = "fee88e92-da9f-5958-a910-48db3264ad38"; + let filter = serde_json::json!({"kinds": [9], "#h": [starter]}); + let body = serde_json::json!({ + "v": 1, "origin": "o", "generation": 1, "active": true, + "transport": "apns", "endpoint": "token", + "subscriptions": [{"filter": filter, "class": "default", "ignore": [filter]}] + }); + let body = parse_plaintext(&body.to_string(), 4096).unwrap(); + assert_eq!(validate_plaintext(&body, &limits()), Ok(())); + } + + #[test] + fn channel_filter_grammar_and_limits_remain_exact() { + for channel in [ + "123e4567-e89b-42d3-a456-426614174000", + "fee88e92-da9f-5958-a910-48db3264ad38", + "01890abc-def0-7890-abcd-0123456789ab", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + ] { + let filter = serde_json::json!({"kinds": [9], "#h": [channel]}); + assert!(validate_filter(filter.as_object().unwrap(), &limits(), true).is_ok()); + } + let channel = "fee88e92-da9f-5958-a910-48db3264ad38"; + for invalid in [ + String::new(), + channel.to_uppercase(), + channel.replace('-', ""), + format!("{{{channel}}}"), + format!("urn:uuid:{channel}"), + format!("{channel} "), + format!("{channel}\n"), + "gee88e92-da9f-5958-a910-48db3264ad38".into(), + "a".repeat(129), + ] { + let filter = serde_json::json!({"kinds": [9], "#h": [invalid]}); + for require_narrowing in [true, false] { + assert!( + validate_filter(filter.as_object().unwrap(), &limits(), require_narrowing) + .is_err(), + "accepted {invalid:?}" + ); + } + } + let filter = serde_json::json!({"kinds": [9], "#h": [channel]}); + let mut short = limits(); + short.max_string_len = 35; + assert!(validate_filter(filter.as_object().unwrap(), &short, true).is_err()); + let filter = serde_json::json!({"kinds": [9], "#h": vec![channel; 5]}); + assert!(validate_filter(filter.as_object().unwrap(), &limits(), true).is_err()); + } + #[test] fn h_uses_its_advertised_limit_not_the_generic_tag_limit() { let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#h":["123e4567-e89b-42d3-a456-426614174000","123e4567-e89b-42d3-a456-426614174001"]},"class":"default"}]}"##, 4096).unwrap(); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 761c9e8d9e..5a52c7fc68 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -348,7 +348,7 @@ fn push_descriptor( "current": true }], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, - "h_grammar": "uuid-v4-lowercase", + "h_grammar": "uuid-lowercase", "class_support": {"apns": ["default"]}, "limitation": { "max_lease_ttl": 2592000, @@ -553,6 +553,7 @@ mod tests { let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example")) .expect("configured push descriptor"); assert_eq!(descriptor["origin"], "ws://tenant.example"); + assert_eq!(descriptor["h_grammar"], "uuid-lowercase"); assert_eq!( descriptor["push_kinds"], serde_json::json!(crate::handlers::push_lease::PUSH_KINDS) diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 7de9cdb9ca..48a799478f 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -804,7 +804,8 @@ mod tests { let member = nostr::Keys::generate(); let outsider = nostr::Keys::generate(); let sender = nostr::Keys::generate(); - let legacy = uuid::Uuid::new_v4(); + // Starter channels use UUIDv5. Exact scope and membership must still hold. + let legacy = uuid::Uuid::parse_str("fee88e92-da9f-5958-a910-48db3264ad38").unwrap(); let system = uuid::Uuid::new_v4(); let future = uuid::Uuid::new_v4(); let author = |keys: &nostr::Keys| keys.public_key().to_bytes().to_vec(); diff --git a/docs/buzz-push.md b/docs/buzz-push.md index 34b76655e9..f2f3af6599 100644 --- a/docs/buzz-push.md +++ b/docs/buzz-push.md @@ -19,3 +19,18 @@ stable specification after validating the design through launch and use. - Publish the matching gateway image and chart, update deployment configuration, then enable the new relay capability. Verify enrollment, delivery, renewal and revocation on a physical device before production activation. + +## Channel identifier grammar + +Push filters use the same UUID identifiers as Buzz channels, including UUIDv5 +starter channels. The relay advertises `h_grammar: "uuid-lowercase"`: exactly +36 lowercase ASCII characters in hyphenated `8-4-4-4-12` hexadecimal form, +without a version or variant restriction. Canonical text is required because +filters match event tags exactly. Array and string limits, community scoping, +and channel membership checks still apply. + +Mobile accepts both this descriptor and the older `uuid-v4-lowercase` +descriptor. Older relays continue to reject non-v4 channel filters. Deploy +mobile support for `uuid-lowercase` before changing the relay advertisement: +older mobile clients reject that descriptor and cannot renew their push leases. +The new grammar does not alter existing UUIDv4 subscriptions. diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 38ee0eca4c..f0ee78d53e 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -130,7 +130,7 @@ On acceptance the executor returns `OK true` and commits the stored event, the e Each subscription `filter` is a NIP-01 filter object under these restrictions — a *restriction* of NIP-01, so the executor's existing matcher runs unchanged and all grammar work is sunk at write time: 1. **Narrowing selector.** Each filter MUST contain at least one of: `#p` (self only), `#h` (1–`max_h` channels), or `authors` (1–`max_authors` pubkeys). Bare kinds-only, since-only, or empty filters MUST be rejected (`invalid: lease filter not narrowed`). -2. **Exact values only.** Every `authors` and `#p` value MUST be exactly 64 lowercase hex characters (a full pubkey), and every `#e` value exactly 64 lowercase hex characters (a full event id); anything shorter, longer, or mixed-case is rejected (`invalid: non-exact match value`). This forecloses NIP-01 prefix matching from inside a lease. Each `#h` value MUST be a non-empty string of at most `max_string_len` bytes and MUST additionally satisfy the channel-identifier grammar the descriptor names in `h_grammar` (e.g. `"uuid-v4-lowercase"` for Buzz); an executor MUST reject values failing its advertised grammar. +2. **Exact values only.** Every `authors` and `#p` value MUST be exactly 64 lowercase hex characters (a full pubkey), and every `#e` value exactly 64 lowercase hex characters (a full event id); anything shorter, longer, or mixed-case is rejected (`invalid: non-exact match value`). This forecloses NIP-01 prefix matching from inside a lease. Each `#h` value MUST be a non-empty string of at most `max_string_len` bytes and MUST additionally satisfy the channel-identifier grammar the descriptor names in `h_grammar` (e.g. `"uuid-lowercase"` for Buzz); an executor MUST reject values failing its advertised grammar. 3. **Self-scoped `#p`.** Every `#p` value MUST equal the lease author (`invalid: p-tag must be self`). A lease MUST NOT register a wake on another user's mentions — that is a surveillance primitive, and it would signal the existence of events the author may not read. 4. **Bounded, allow-listed kinds.** Each filter MUST include `kinds` (1–`max_kinds` entries), each drawn from the executor's advertised `push_kinds` (`invalid: kind not push-eligible`). Ephemeral kinds (20000–29999), presence, typing, and relay-signed snapshot kinds MUST NOT be push-eligible. 5. **No time-travel, no ids, no limit, no search.** `since`, `until`, `ids`, `limit`, and `search` MUST be rejected, not silently ignored. The lease's liveness window is its `expiration`; `ids` waking is nonsensical for future events. @@ -177,7 +177,7 @@ Until this draft has an upstream NIP number, executors MUST NOT advertise it in { "id": "com.example.app/android", "transport": "fcm" } ], "push_kinds": [9, 1059, 40007, 46010, 7], "urgent_kinds": [46010], - "h_grammar": "uuid-v4-lowercase", + "h_grammar": "uuid-lowercase", "class_support": { "apns": ["silent","default","time_sensitive","urgent"], "fcm": ["silent","default","time_sensitive","urgent"] }, "limitation": { @@ -448,5 +448,8 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - `exec` tag: executor encryption-key identifier for `kind:30350` - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery - Classes: `silent`, `default`, `time_sensitive`, `urgent` -- `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) +- `h_grammar` values: + - `"uuid-lowercase"`: exactly 36 ASCII characters in lowercase hyphenated UUID form (`8-4-4-4-12` hexadecimal digits). No UUID version or variant restriction. Buzz uses this grammar because channel IDs include UUIDv5 starter channels as well as UUIDv4 channels. + - `"uuid-v4-lowercase"`: the legacy Buzz grammar, restricted to UUID version 4 in canonical lowercase hyphenated form. + - Origins may register additional grammars with this NIP. - Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 26d69538c5..2c5540d7bf 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -115,7 +115,9 @@ class BuzzPushLeaseDescriptor { ); } final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); - if (hGrammar != 'uuid-v4-lowercase') { + // Older relays advertise the narrower grammar. Keep UUIDv4 leases working + // during rollout; those relays still reject non-v4 channels themselves. + if (hGrammar != 'uuid-lowercase' && hGrammar != 'uuid-v4-lowercase') { throw const FormatException('Unsupported push h_grammar'); } diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 41ef1808fe..761984b20b 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -15,8 +15,10 @@ const buzzPushHellthreadParticipantLimit = 20; const _supportedNotificationClasses = {'default'}; const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); +// Channel IDs use canonical UUID text; starter channels are UUIDv5. +// Match the relay's UUID parser without imposing a version or variant. final _channelIdPattern = RegExp( - r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', ); enum BuzzPushLeaseSubscriptionAuthority { desired, accepted } diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart index 1fd979a3b3..0c7b41b458 100644 --- a/mobile/test/shared/push/dev_push_lease_test.dart +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -22,6 +22,17 @@ void main() { final grant = _grant(relay.public); final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000); + for (final grammar in ['uuid-lowercase', 'uuid-v4-lowercase']) { + test('accepts the $grammar relay descriptor', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = grammar; + expect( + BuzzPushLeaseDescriptor.fromRelayInformation(information).origin, + descriptor.origin, + ); + }); + } + test('legacy capability does not enable Buzz push', () { final information = _descriptorJson(relay.public); information['supported_extensions'] = ['nip-pl']; @@ -41,7 +52,11 @@ void main() { memberPubkey: signer.public, subscriptions: [ BuzzPushSubscription( - filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + filter: BuzzPushFilter( + kinds: const [9], + pTags: [signer.public], + hTags: const ['fee88e92-da9f-5958-a910-48db3264ad38'], + ), notificationClass: 'default', ), ], @@ -92,6 +107,7 @@ void main() { 'filter': { 'kinds': [9], '#p': [signer.public], + '#h': ['fee88e92-da9f-5958-a910-48db3264ad38'], }, 'class': 'default', }, @@ -513,7 +529,7 @@ Map _descriptorJson(String relayPubkey) => { {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, ], 'push_kinds': [9, 40002, 45001, 45003], - 'h_grammar': 'uuid-v4-lowercase', + 'h_grammar': 'uuid-lowercase', 'class_support': { 'apns': ['default'], }, diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 3ef21938e2..bd2bf53d93 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -8,6 +8,56 @@ void main() { const channelA = '123e4567-e89b-42d3-a456-426614174000'; const channelB = '123e4567-e89b-42d3-a456-426614174001'; + test('subscribes to and mutes UUIDv5 starter channels', () { + const starter = 'fee88e92-da9f-5958-a910-48db3264ad38'; + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [starter, channelA], + ); + expect(subscriptions.last.filter.hTags, [channelA, starter]); + final muted = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [starter, channelA], + mutedChannelIds: [starter], + ); + expect(muted.last.filter.hTags, [channelA]); + expect(muted.first.ignore.last.hTags, [starter]); + expect( + BuzzPushSubscription.fromJson(muted.first.toJson()).toJson(), + muted.first.toJson(), + ); + }); + + test('channel filters preserve exact canonical UUID text', () { + for (final channel in [ + channelA, + 'fee88e92-da9f-5958-a910-48db3264ad38', + '01890abc-def0-7890-abcd-0123456789ab', + '00000000-0000-0000-0000-000000000000', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + ]) { + final filter = BuzzPushFilter(kinds: [9], hTags: [channel]); + expect(BuzzPushFilter.fromJson(filter.toJson()).hTags, [channel]); + } + for (final channel in [ + '', + channelA.toUpperCase(), + channelA.replaceAll('-', ''), + '{$channelA}', + 'urn:uuid:$channelA', + '$channelA ', + '$channelA\n', + 'g23e4567-e89b-42d3-a456-426614174000', + 'a' * 129, + ]) { + expect( + () => BuzzPushFilter(kinds: [9], hTags: [channel]), + throwsFormatException, + reason: channel, + ); + } + }); + test( 'desired subscription state round-trips with an accepted-state seam', () {