mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(push): accept canonical channel UUIDs across versions
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
() {
|
||||
|
||||
Reference in New Issue
Block a user