A Concord v2 message that is already expired when it arrives is saved and delivered to the message handler. CORD-08 §3 says a reader must refuse it.
What happens
The Message arm of apply_chat_to_state does not check the rumor's NIP-40 expiration:
|
ChatEvent::Message { opened, reply_to, emoji } => { |
|
let msg = chat_message_to_message(opened, reply_to, emoji, my_pubkey); |
|
// DB dedup: a known inner id is already stored — don't re-ingest/re-emit (a |
|
// catch-up sweep re-fetches the whole page; in-memory STATE holds only a window). |
|
if crate::db::events::event_exists(&msg.id).unwrap_or(false) { |
|
return None; |
|
} |
|
state.ensure_community_chat(channel_id); |
|
// Persist regardless of the STATE-add result: `event_exists` already proved |
|
// it's not in the DB, so a `false` here means only that another writer put it |
|
// in STATE first — the row must still be saved, or it's lost until re-fetch. |
|
state.add_message_to_chat(channel_id, &msg); |
|
Some(ChatPersist::New(msg)) |
|
} |
So an expired message is saved to the database, and on_community_message fires for it on the live path:
|
inbound::DispatchedV2::Chat { channel_id, event } => { |
|
match inbound::persist_chat_event(&event, &channel_id, &my_pk).await { |
|
// "New" = first ingest, which a relay-backfill replay of a month-old |
|
// event also satisfies — is_new comes from the inner send time so |
|
// stale replays persist + surface without ringing anything. |
|
Some(inbound::ChatPersist::New(message)) => { |
|
let fresh = crate::community::is_realtime_fresh(message.at); |
|
handler.on_community_message(&channel_id, &message, fresh); |
|
} |
Backfill goes through the same apply_chat_to_state.
The DM path already drops this case on receipt:
|
let expiration = extract_nip40_expiration(&rumor); |
|
// NIP-40: an event arriving already expired is dropped on receipt — it |
|
// must never render or persist. |
|
if already_expired(expiration) { |
|
return Ok(RumorProcessingResult::Ignored); |
|
} |
When it happens
nostr-sdk drops an event whose outer expiration tag is in the past, so a normal expired wrap does not reach apply_chat_to_state. But CORD-08 §2 says the wrap tag is for relays, and "readers judge by the rumor's copy alone". Any member of the channel can re-wrap the signed seal without the outer tag, or with a later one. That wrap reaches the client, and the expired message is stored.
In the app, the self-destruct sweeper removes the row a few seconds later. SDK and CLI hosts do not run the sweeper (self_destruct::start_sweeper has no caller), so a bot keeps it.
Spec
CORD-08 §3: "MUST refuse an already-expired rumor at ingest — never store it".
Fix
I have a fix with a test. The Message arm returns None when the rumor's expiration is already in the past. It uses the same already_expired check as the DM path. I will open a PR against widescreen.
A Concord v2 message that is already expired when it arrives is saved and delivered to the message handler. CORD-08 §3 says a reader must refuse it.
What happens
The Message arm of
apply_chat_to_statedoes not check the rumor's NIP-40expiration:Vector/crates/vector-core/src/community/v2/inbound.rs
Lines 137 to 150 in 9525cf5
So an expired message is saved to the database, and
on_community_messagefires for it on the live path:Vector/crates/vector-core/src/community/v2/realtime.rs
Lines 592 to 600 in 9525cf5
Backfill goes through the same
apply_chat_to_state.The DM path already drops this case on receipt:
Vector/crates/vector-core/src/rumor.rs
Lines 316 to 321 in 9525cf5
When it happens
nostr-sdk drops an event whose outer
expirationtag is in the past, so a normal expired wrap does not reachapply_chat_to_state. But CORD-08 §2 says the wrap tag is for relays, and "readers judge by the rumor's copy alone". Any member of the channel can re-wrap the signed seal without the outer tag, or with a later one. That wrap reaches the client, and the expired message is stored.In the app, the self-destruct sweeper removes the row a few seconds later. SDK and CLI hosts do not run the sweeper (
self_destruct::start_sweeperhas no caller), so a bot keeps it.Spec
CORD-08 §3: "MUST refuse an already-expired rumor at ingest — never store it".
Fix
I have a fix with a test. The Message arm returns
Nonewhen the rumor's expiration is already in the past. It uses the samealready_expiredcheck as the DM path. I will open a PR againstwidescreen.