Files
archy/docs/peer-request-replay.md

4.6 KiB

Peer-request replay protection

A relay poll can return the same signed encrypted request many times. Rejecting or cancelling a request must not make that old event a new pending request. Conversely, rejecting one request is not a permanent block on the sender.

Storage and admission

The receiver verifies the Nostr envelope before carrying its event ID and signed creation time into inbound request admission. A production insertion requires that metadata; the metadata-free insertion helper exists only for legacy test fixtures. Approval, membership and DID verification remain separate operations.

pending_requests.json now retains request-event receipts alongside its request rows. Both are written together using the existing locked, atomic persistence path. Receipts survive approval, rejection, cancellation/deletion and subsequent request-store writes. Different events coalesced into an existing pending request are recorded too, so those events cannot reappear after the decision.

The existing 30-day request lifetime also bounds replay-receipt retention. Events older than that window cannot acquire a fresh lifetime by being polled again. Future-dated events are deferred until their signed creation time arrives; no approval or new pending row is inferred from them.

Receipt storage admits at most 256 distinct events per sender and 4096 overall inside that window. Saturation returns a rate-limit error, rather than evicting unexpired receipts and reopening old requests. These are defensive admission limits, not a guarantee of delivery during request flooding. Existing valid receipts still suppress replay at capacity. Expired receipts are pruned on subsequent event admission checks.

Existing data and compatibility

Old request files remain readable. Rows created without an event ID use a conservative migration check: events from the same sender/DID created no later than that row's recorded receipt time are not reopened. Invalid legacy timestamps fail closed and retain the prior data for diagnosis. This cannot reconstruct an unknown historical event ID or prove remote clock correctness. Once a verified event is suppressed by this migration check, its ID and signed creation time are saved as a receipt before successful suppression is reported. Removing the legacy row then preserves suppression of that known event. This uses the same capacity checks as new-event admission; saturation returns an error without claiming a receipt was saved. Invalid legacy data remains unchanged. New event-aware rows can distinguish different events even within the same second.

The new fields are additive, but an older binary does not implement replay protection and may discard receipts when it rewrites the store. Do not claim mixed-writer or downgrade replay safety. Deployment/rollback plans must preserve the exact private store backup and identify the old-version behavioural limit; never clear request history to make a rollout appear successful.

Unresolved decision-message correlation

Request-event receipts do not bind a peer-cancel or peer-reject to the request it decides. Existing wire messages carry only an optional reason, and handlers match sender plus state. An old signed decision can therefore affect a newer request, including after restart or on delayed first delivery. Exact-generation correlation needs an approved wire and legacy/mixed-version policy; this storage correction does not change or solve that contract. C01 late-response acceptance remains blocked. A timestamp-only cutoff cannot distinguish same-second requests.

Qualification boundaries

The restoration correction has only source-assertion RED/GREEN evidence. Its three added Rust regressions cover legacy suppression/delete/disk reload and same-second distinct events, both capacity limits, and invalid Pending legacy timestamps. They have NOT been compiled or executed in this packet. The storage delete test uses the function called by PeerCancel; it is not a signed RPC test.

Regression tests exercise persistent rejection, same-second new requests, coalesced requests, deletion, unrelated writes, concurrent delivery, expiry, invalid metadata, legacy rows and capacity refusal. A signed NIP-44 loopback relay test exercises actual polling, rejection RPC and handler recreation, then admits a new event from the same sender without granting membership.

These tests run through scripts/test-backend-isolated.sh. They do not establish installed-node, public-relay, mixed-version or operator acceptance. Independent protocol/storage review and installed reject/reload/restart/new-request checks remain required before declaring the reported issue resolved.