§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.
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_idalready in the cache MUST be rejected withREPLAY_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
Correct message processing order:
- Parse envelope (cheap)
- Check
rcan_version(cheap) - Replay check: timestamp freshness + msg_id seen-set (cheap)
- JWT authentication (moderate)
- Signature verification (expensive)
- Authorization / scope check
- Process payload
ESTOP Safety Invariant
Specifically:
- SAFETY messages (type 6) use a 10s replay window (not 30s)
- A fresh ESTOP with a duplicate msg_id: process ESTOP, log
REPLAY_DETECTEDwarning, 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 Code | Cause | HTTP / 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
- §8.4 Time Synchronization — required for accurate timestamp comparison
- QoS & Delivery Guarantees — interaction between replay prevention and QoS retry
- §3.5 Version Compatibility — replay check precedes version check? No: version check is first, replay check is third
- Error Codes — REPLAY_DETECTED, MESSAGE_STALE