Skip to content

Concord v2: an already-expired message is stored at ingest #93

Description

@TheSeydiCharyyev

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions