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:
Will Pfleger
2026-06-23 11:10:16 -04:00
committed by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent f9c60452d0
commit 556496ae50

View File

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