Skip to content

Registrar does not enforce single-use semantics on tickets (replay within validity window) #69

Description

@srene

Summary

Even with advertiser binding fixed (#68), the registrar in p2p/discover/v5_udp.go does not track which tickets have already been redeemed. A valid ticket can be re-presented multiple times within its 5-second ticketValidityWindow, and each presentation walks the admission path independently.

This is a defence-in-depth concern: combined with the missing advertiser binding it produces the Sybil-sharing attack described in #68; on its own it allows an honest advertiser (or one whose identity-binding check passes) to retry the same ticket repeatedly inside the validity window, which is wasted work on the registrar at best and a replay DoS surface at worst.

What the registrar currently does

TicketSealer.Unpack (p2p/discover/topicindex/ticket.go:93) verifies:

  1. MAC over encoded struct (registrar-binding)
  2. Topic matches the REGTOPIC's topic field
  3. now ∈ [LastUsed + WaitTimeIssued, LastUsed + WaitTimeIssued + ticketValidityWindow] (timing window)

It does not record that the ticket was just used. The REGTOPIC handler then calls topicTable.Register(...), which is the admission path proper. Nothing along the way prevents the same ticket bytes from being submitted again inside the 5s window and walking the admission path a second time.

Attack / failure modes

  1. Combined with Tickets can be reused by multiple registrants (missing advertiser binding) #68 (no advertiser binding): Sybil swarm fans the ticket out and all redeem in the validity window. One wait → N admissions.

  2. Replay DoS by one advertiser: even after Tickets can be reused by multiple registrants (missing advertiser binding) #68 is fixed, the legitimate advertiser can flood the same ticket back at the registrar in a 5-second burst. The topic-table admission code dedupes per source per bucket so it won't produce multiple slots for the same identity, but each presentation still:

    • Runs MAC verification (SHA-256 over 58 bytes — cheap but non-zero)
    • Calls topicTable.Register which traverses the IP-similarity tree and waiting-time calculations
    • Issues either an admission or a fresh ticket back

    At Sybil-scale this becomes a useful amplification primitive.

  3. Subtle interaction with renewal: a ticket emitted by the registrar carries LastUsed as the moment it was issued. The advertiser is expected to wait WaitTimeIssued, then redeem within ticketValidityWindow = 5s. If the advertiser submits the same ticket multiple times, the registrar's view of timing doesn't advance per request — each one looks "fresh" because LastUsed is in the ticket bytes, not in the registrar's state. So nothing local moves forward to invalidate the second presentation.

Proposed fix

Track recently-redeemed tickets at the registrar and reject the second presentation. Smallest sufficient change:

  • Maintain a map[ticketDigest]struct{} of recently-redeemed tickets in topicSystem or alongside TicketSealer, where ticketDigest = sha256(ticketBytes) (cheap, 32 bytes per entry).
  • On every redemption, check whether the digest is already in the set; if yes, reject the REGTOPIC.
  • Insert the digest with a TTL >= ticketValidityWindow (e.g. 2 * ticketValidityWindow = 10s). After the window closes, the timing check rejects the ticket anyway, so the set entry can be reaped.
  • Reap entries lazily (sweep on insert when the set crosses a soft cap; or on a periodic timer).

Memory bound: at sustained-burst throughput of r REGTOPIC/sec the cache holds ~r × 10s entries; even at 10k REG/s that's 100k × 32 bytes = 3.2 MB. Per-registrar cost is bounded by the registrar's own admission rate (legitimate or not), and the cache is bounded in time by the validity window — no unbounded growth.

Alternative considered: invalidate by incrementing LastUsed on every retry. This requires registrar-side state keyed by something stable (the advertiser ID after #68, or the original FirstIssued), and produces near-identical semantics with more bookkeeping. The digest-cache is simpler and harder to get wrong.

Tests to add

  • topicindex/ticket_test.go: pack a ticket, unpack it once (succeeds), unpack the identical bytes a second time within the validity window → expect a "ticket already used" error.
  • Integration test: REGTOPIC with a previously-redeemed ticket → registrar responds as if no ticket were presented (i.e., issues a fresh ticket with a fresh wait), not as if the wait had been honoured.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions