fix(push): accept canonical channel UUIDs across versions

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-26 11:49:56 -07:00
parent 0e36dbc823
commit 0f5acd42cb
9 changed files with 156 additions and 10 deletions

View File

@@ -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();

View File

@@ -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)

View File

@@ -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();

View File

@@ -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.

View File

@@ -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`

View File

@@ -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');
}

View File

@@ -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 }

View File

@@ -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<String, dynamic>)['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<String, dynamic> _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'],
},

View File

@@ -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',
() {