§8.3 — Replay Attack Prevention v1.5

Without replay prevention, a captured signed command is valid forever. v1.5 adds a mandatory timestamp freshness bound and a sliding-window msg_id seen-set to block replayed messages.

Deployment blocker: Any RCAN robot accessible over a network is vulnerable to replay attacks without this protection. All v1.5-compliant implementations MUST enforce replay checking.

Threat Model

An attacker with passive network access captures a valid signed COMMAND message (e.g., move_forward signed by the robot's owner). Because RCAN messages carry Ed25519 signatures, the attacker cannot forge a new command — but they can replay the captured one unchanged, indefinitely. The robot has no way to distinguish a fresh command from a replayed one without timestamp freshness enforcement and a msg_id deduplication cache.

Replay Check Rules

1. Timestamp Freshness

Robots MUST reject any message where:

now() - message.timestamp > replay_window_s
  • Default replay_window_s: 30 seconds
  • Configurable range: 5–300 seconds (via security.replay_window_s)
  • For SAFETY messages (type 6): window MUST NOT exceed 10 seconds
  • Clock drift tolerance: ±5s (enforced by §8.4 time sync requirement)

2. msg_id Seen-Set (Sliding Window)

Robots MUST maintain a sliding-window cache of recently seen msg_id values:

  • Cache covers the same duration as replay_window_s
  • Any message with a msg_id already in the cache MUST be rejected with REPLAY_DETECTED
  • Cache entries are evicted when they age out of the window
  • Max cache size: configurable via security.msg_id_cache_size (default: 10,000 entries)

3. Check Ordering — Anti-DoS

Critical: Replay checks MUST be applied BEFORE signature verification. Signature verification is expensive (Ed25519). An attacker flooding replayed messages would force expensive crypto operations on every packet without this ordering.

Correct message processing order:

  1. Parse envelope (cheap)
  2. Check rcan_version (cheap)
  3. Replay check: timestamp freshness + msg_id seen-set (cheap)
  4. JWT authentication (moderate)
  5. Signature verification (expensive)
  6. Authorization / scope check
  7. Process payload

ESTOP Safety Invariant

Protocol 66 Invariant: ESTOP is never blocked by replay checking when the timestamp is fresh. A fresh ESTOP (within 10s) from any identified source MUST always be processed, even if a duplicate msg_id is seen. The replay cache is checked, but ESTOP receipt is not gated on it — ESTOP is processed immediately, and the duplicate is logged.

Specifically:

  • SAFETY messages (type 6) use a 10s replay window (not 30s)
  • A fresh ESTOP with a duplicate msg_id: process ESTOP, log REPLAY_DETECTED warning, do not reject
  • A stale ESTOP (> 10s old): reject — this prevents confusion from severely delayed network delivery
  • RESUME requires fresh timestamp AND unique msg_id (not exempt from replay check)

Configuration

security:
  replay_window_s: 30        # Default: 30; range 5–300
  msg_id_cache_size: 10000   # Max entries in seen-set; evicted by age
  # Note: SAFETY messages always use min(replay_window_s, 10) internally

Error Responses

Error CodeCauseHTTP / Response
REPLAY_DETECTED msg_id already seen within replay window HTTP 409 / COMMAND_NACK (type 31)
MESSAGE_STALE timestamp older than replay_window_s HTTP 408 / COMMAND_NACK (type 31)

Both errors MUST be written to the audit trail. Do NOT include the attacker's payload in the audit record (prevents audit log injection).

rcan-py Implementation

from rcan.validate import ReplayCache, ReplayAttackError

# Initialize at robot startup
replay_cache = ReplayCache(
    window_s=config.security.replay_window_s,   # default 30
    max_size=config.security.msg_id_cache_size,  # default 10000
)

# In message receive path (before signature verification)
try:
    replay_cache.check(msg.msg_id, msg.timestamp, msg_type=msg.type)
except ReplayAttackError as e:
    return error_response("REPLAY_DETECTED", str(e))

See Also