Skip to content

A solved PoW challenge can be redeemed twice at the expiry boundary #274

Description

@0xGr1mm

Summary

The PoW rate limiter has two different definitions of "expired", and they disagree by exactly one second. In that second an already solved challenge can be redeemed a second time, without doing any new proof of work.

Challenge::is_expired treats a challenge as valid while current_time - timestamp <= challenge_lifetime:

https://github.com/0xMiden/faucet/blob/next/crates/pow/src/challenge.rs#L117-L120

The challenge cache, meanwhile, stops rate limiting a solver as soon as challenge_lifetime seconds have elapsed since their last submission, because next_challenge_delay saturates to 0 at exactly that point:

https://github.com/0xMiden/faucet/blob/next/crates/pow/src/challenge_cache.rs#L94-L98

ChallengeCache::drop_expired_challenges uses that same >= lifetime boundary. So at t = submission + challenge_lifetime the solver is free again, while the challenge they already used is still considered valid.

Steps to reproduce

With the default --pow-challenge-lifetime of 30s, a single (account_id, api_key) solver:

  1. GET /pow for their account, solve the challenge once (issued at t0).
  2. GET /get_tokens with that challenge and nonce at t0 — accepted, tokens minted.
  3. GET /get_tokens again with the same challenge and the same nonce at t0 + 30 — accepted again, tokens minted again.

No API key is needed, since domain defaults when none is supplied.

As a unit test against next (crates/pow/src/tests.rs), which passes today:

let challenge = Challenge::new(target, issued_at, request_complexity, requestor, domain, secret);
let nonce = find_pow_solution(&challenge, 10000).unwrap();

// Redeemed once, as intended.
assert!(pow.submit_challenge(requestor, domain, &challenge, nonce, issued_at, request_complexity).is_ok());

// Redeemed a second time with the same solution, one lifetime later.
let t = issued_at + pow.config.challenge_lifetime.as_secs();
assert!(pow.submit_challenge(requestor, domain, &challenge, nonce, t, request_complexity).is_ok());

Expected behaviour

The second submission is rejected. validate_get_tokens_params already documents this as an error condition — "the challenge has already been used" — but nothing enforces it:

https://github.com/0xMiden/faucet/blob/next/bin/faucet/src/api/get_tokens.rs#L188-L195

Actual behaviour

The second submission succeeds, so one proof of work yields two mints.

Impact

The PoW cost per mint is halved for anyone who redeems on the boundary second. The window is only one second wide, but it is a whole second of wall clock at a predictable offset from a timestamp the server itself hands out in the /pow response, so hitting it from a script is not difficult. This is a faucet, so the direct cost is testnet tokens and the node capacity spent minting them, rather than anything of value.

I want to be clear about what this is not: it does not require a contrived clock or an unusual configuration, and it reproduces on the default 30s lifetime.

Notes

The two boundaries need to agree, and the cache's >= lifetime is the one that keeps the rate limit meaningful: a challenge has to stop being valid no later than the moment its solver stops being rate limited. Making is_expired use >= does that, and gives challenge_lifetime its natural reading of "valid for N seconds" (diffs 0..=N-1) rather than N+1 distinct seconds.

I have a fix ready with regression tests and can open a PR. One thing worth flagging in advance: requestor_is_rate_limited_after_challenge_expires currently depends on the boundary second still being valid, so it needs updating. It submits 2.9s into a 3s lifetime, which under whole-second timestamps can land on either side of the boundary, so it is also flaky today independently of this change; I made it deterministic by driving it with explicit timestamps instead of sleeps.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions