Context
The replay check in kanon-core/src/verify.rs:91-98 is a linear scan over ctx.seen_nonces, and normalize_hex allocates a fresh String for every element on every call. seen_nonces is Vec<String> (model.rs:93) with no cap and no dedup.
An external benchmark measured the cost. With the target nonce absent, verify degrades from ~187 us at an empty set to ~10.3 ms at 500k entries, roughly 55x the cost of the signature recovery itself. RSS grows to ~104 MB at 500k because every nonce is held as a raw hex String and re-allocated during the scan. The scan cost equals the crypto cost at around 10k entries.
This is invisible in CI because all nine corpus vectors carry empty replay sets. It also makes the verifier a CPU-exhaustion surface for any caller that passes an untrusted or unbounded consumed-nonce set. It never crashes, it just gets arbitrarily slow.
Proposed fix
- Normalize each nonce once at
Context / BareOptions construction (strip 0x, lowercase, decode to [u8; 32]) and store in a HashSet<[u8; 32]>.
- The check becomes one normalize of the candidate plus one O(1)
contains.
- Reject nonces that do not decode to 32 bytes at construction, with a clear error, rather than silently never matching.
- Document a recommended bound on the seen-nonces set at the CLI boundary.
Acceptance
- Verify time is flat in seen-nonces set size.
- A corpus or test vector exercises
NONCE_REPLAY against a large seen-nonces set so the path is measured, not just asserted non-empty.
- Python crosscheck unaffected. The wire format and reason codes do not change. This is internal to the verification context.
Context
The replay check in
kanon-core/src/verify.rs:91-98is a linear scan overctx.seen_nonces, andnormalize_hexallocates a freshStringfor every element on every call.seen_noncesisVec<String>(model.rs:93) with no cap and no dedup.An external benchmark measured the cost. With the target nonce absent, verify degrades from ~187 us at an empty set to ~10.3 ms at 500k entries, roughly 55x the cost of the signature recovery itself. RSS grows to ~104 MB at 500k because every nonce is held as a raw hex
Stringand re-allocated during the scan. The scan cost equals the crypto cost at around 10k entries.This is invisible in CI because all nine corpus vectors carry empty replay sets. It also makes the verifier a CPU-exhaustion surface for any caller that passes an untrusted or unbounded consumed-nonce set. It never crashes, it just gets arbitrarily slow.
Proposed fix
Context/BareOptionsconstruction (strip0x, lowercase, decode to[u8; 32]) and store in aHashSet<[u8; 32]>.contains.Acceptance
NONCE_REPLAYagainst a large seen-nonces set so the path is measured, not just asserted non-empty.