mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
fix(relay): derive E2E gate from channel-scoped acceptance surface
The 15c latch gate was a hand-maintained kind list running parallel to the relay's actual channel-scoped acceptance surface (requires_h_channel_ scope), and the two drifted: 40004-40007 (pinned/bookmarked/scheduled/ reminder) were accepted into a latched DM but never gated, so a plaintext scheduled message or reminder body would be stored cleartext — the same leak class already fixed for the edit path. Gate all four. Pinned (40004) and bookmarked (40005) have no SDK builder or relay schema constraining their content, so a client can place free text in the body; with no proof they are bodyless and no legitimate plaintext producer to break, fail-visible rejection is the safe default. Add a drift-guard test that enumerates requires_h_channel_scope over the kind space and asserts every channel-scoped kind is classified as either E2E-gated (free-text body) or explicitly bodyless. A new channel-scoped kind added without classification now fails the test, so the gate cannot silently drift behind the acceptance surface again. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
committed by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
f9c60452d0
commit
556496ae50
@@ -409,21 +409,34 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool {
|
||||
}
|
||||
|
||||
/// Channel-scoped kinds that carry a free-text display body. In a latched E2E
|
||||
/// DM these bodies MUST be NIP-44 ciphertext (rule 15c) — gating kind:9 alone
|
||||
/// left the edit (40003), v2 (40002), diff (40008), forum comment (45003),
|
||||
/// forum post (45001), and canvas (40100) paths able to land plaintext into a
|
||||
/// latched channel, the exact leak the latch exists to prevent. None of these
|
||||
/// are global-only kinds, so each carries an `h` tag and resolves to a channel
|
||||
/// at ingest. The boundary is "carries a free-text body", not membership in any
|
||||
/// one enumeration of channel kinds: structured-content kinds (forum vote
|
||||
/// `+`/`-`, reactions, deletions, membership/admin/typing — empty content) and
|
||||
/// global-only kinds (kind:1) stay out because they have no user text to leak.
|
||||
/// DM these bodies MUST be NIP-44 ciphertext (rule 15c), or the relay would
|
||||
/// silently store plaintext — the exact leak the latch exists to prevent.
|
||||
///
|
||||
/// This is the content-bearing half of the relay's channel-scoped acceptance
|
||||
/// surface (`requires_h_channel_scope`). The two must stay in lockstep: any kind
|
||||
/// the relay accepts as channel-scoped is either gated here (free-text body) or
|
||||
/// listed as bodyless in the `e2e_drift_guard` test. That guard derives from
|
||||
/// `requires_h_channel_scope` and fails if a new channel-scoped kind is added
|
||||
/// without classification, so this list cannot silently drift behind the
|
||||
/// acceptance surface (the bug that left 40003-40007 ungated across two passes).
|
||||
///
|
||||
/// 40004 (pinned) and 40005 (bookmarked) are gated despite having no SDK builder
|
||||
/// or relay-side schema: they are named "a stream message that has been
|
||||
/// pinned/bookmarked" and are accepted as persisted channel events with no
|
||||
/// structure constraining their content, so a client can place free text in the
|
||||
/// body. With no proof they are bodyless (unlike forum vote's `+`/`-`) and no
|
||||
/// legitimate plaintext producer to break, fail-visible rejection is the safe
|
||||
/// default for the latch boundary.
|
||||
pub(crate) fn is_e2e_enforced_content_kind(kind: u32) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
KIND_STREAM_MESSAGE
|
||||
| KIND_STREAM_MESSAGE_V2
|
||||
| KIND_STREAM_MESSAGE_EDIT
|
||||
| KIND_STREAM_MESSAGE_PINNED
|
||||
| KIND_STREAM_MESSAGE_BOOKMARKED
|
||||
| KIND_STREAM_MESSAGE_SCHEDULED
|
||||
| KIND_STREAM_REMINDER
|
||||
| KIND_STREAM_MESSAGE_DIFF
|
||||
| KIND_FORUM_COMMENT
|
||||
| KIND_FORUM_POST
|
||||
@@ -2281,12 +2294,17 @@ mod tests {
|
||||
// The latch-enforcement gate must span every channel-scoped kind that
|
||||
// carries a free-text display body — not kind:9 alone. A plaintext edit
|
||||
// (40003), v2 message (40002), diff (40008), forum comment (45003),
|
||||
// forum post (45001), or canvas (40100) into a latched DM is the exact
|
||||
// leak the latch exists to prevent.
|
||||
// forum post (45001), canvas (40100), scheduled message (40006),
|
||||
// reminder (40007), pinned (40004), or bookmarked (40005) into a latched
|
||||
// DM is the exact leak the latch exists to prevent.
|
||||
for kind in [
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_STREAM_MESSAGE_PINNED,
|
||||
KIND_STREAM_MESSAGE_BOOKMARKED,
|
||||
KIND_STREAM_MESSAGE_SCHEDULED,
|
||||
KIND_STREAM_REMINDER,
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_FORUM_COMMENT,
|
||||
KIND_FORUM_POST,
|
||||
@@ -2314,6 +2332,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel-scoped kinds whose body is structurally bodyless — they carry no
|
||||
/// free-text user content, so they are deliberately NOT E2E-gated. Each must
|
||||
/// be justified by its content shape, not by absence from an enumeration:
|
||||
/// - forum vote: "+"/"-" only;
|
||||
/// - NIP-29 admin (put/remove user, edit metadata, delete event/group,
|
||||
/// leave request): membership/admin commands, empty or structured content;
|
||||
/// - huddle lifecycle (started/joined/left/ended/guidelines): structured
|
||||
/// session state, no message body.
|
||||
///
|
||||
/// This list is the test's accounting of the bodyless half of the
|
||||
/// channel-scoped surface; `e2e_drift_guard` asserts the two halves together
|
||||
/// cover every channel-scoped kind, so a new kind can't slip in unclassified.
|
||||
const BODYLESS_CHANNEL_SCOPED_KINDS: &[u32] = &[
|
||||
KIND_FORUM_VOTE,
|
||||
KIND_NIP29_PUT_USER,
|
||||
KIND_NIP29_REMOVE_USER,
|
||||
KIND_NIP29_EDIT_METADATA,
|
||||
KIND_NIP29_DELETE_EVENT,
|
||||
KIND_NIP29_DELETE_GROUP,
|
||||
KIND_NIP29_LEAVE_REQUEST,
|
||||
KIND_HUDDLE_STARTED,
|
||||
KIND_HUDDLE_PARTICIPANT_JOINED,
|
||||
KIND_HUDDLE_PARTICIPANT_LEFT,
|
||||
KIND_HUDDLE_ENDED,
|
||||
KIND_HUDDLE_GUIDELINES,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn e2e_drift_guard_classifies_every_channel_scoped_kind() {
|
||||
// Structural fix for the kind-set-drift bug class: the E2E gate
|
||||
// (`is_e2e_enforced_content_kind`) is a hand-written list that ran
|
||||
// parallel to the relay's actual channel-scoped acceptance surface
|
||||
// (`requires_h_channel_scope`) and drifted from it, leaving content kinds
|
||||
// ungated. This guard derives directly from the acceptance surface: every
|
||||
// kind the relay accepts as channel-scoped MUST be classified as either
|
||||
// E2E-gated (carries a free-text body) or explicitly bodyless. Adding a
|
||||
// new arm to `requires_h_channel_scope` without classifying it here fails
|
||||
// this test — so the gate can't silently drift behind the surface again.
|
||||
for kind in 0u32..=50_000 {
|
||||
if !requires_h_channel_scope(kind) {
|
||||
continue;
|
||||
}
|
||||
let gated = is_e2e_enforced_content_kind(kind);
|
||||
let bodyless = BODYLESS_CHANNEL_SCOPED_KINDS.contains(&kind);
|
||||
assert!(
|
||||
gated ^ bodyless,
|
||||
"channel-scoped kind {kind} is unclassified: it must be either \
|
||||
E2E-gated (free-text body) or in BODYLESS_CHANNEL_SCOPED_KINDS, \
|
||||
and exactly one of the two — got gated={gated}, bodyless={bodyless}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_dummy_event() -> Event {
|
||||
let keys = nostr::Keys::generate();
|
||||
nostr::EventBuilder::new(nostr::Kind::Custom(9), "")
|
||||
|
||||
Reference in New Issue
Block a user