NAME

Punk::Challenge::Token - the puzzle and the clearance

SYNOPSIS

my %cfg = ( secret => 'k', bits => 16, ttl => 3600, puzzle_ttl => 300 );
my $subject = Punk::Challenge::Token->subject('192.0.2.7');   # 192.0.2.0/24

my $puzzle   = Punk::Challenge::Token->issue(\%cfg, $subject, bits => 16);
my $bits     = Punk::Challenge::Token->verify(\%cfg, $subject, $puzzle, $nonce);

my $cookie   = Punk::Challenge::Token->clear(\%cfg, $subject, bits => $bits);
my $ok       = Punk::Challenge::Token->cleared(\%cfg, $subject, $cookie);

my ($got, $why) = Punk::Challenge::Token->verify(\%cfg, $subject, $solution);

DESCRIPTION

Issue and verify, without a plugin or an application. This is the module a test or a command line reaches for; from a request, the plugin is the way in, and it supplies the configuration and derives the subject from the request.

Everything here is state-free. There is no table, no cache and no replay set. A solved puzzle presented twice within puzzle_ttl is accepted twice, and that buys the presenter nothing: a second clearance for the same subject with the same expiry as the first, which the presenter already holds. What a replay set would defend, sharing a clearance across a botnet, bind defends instead.

The configuration

The same hash the plugin line takes, validated the same way: secret is required and may be a list, newest first; bits, ttl, puzzle_ttl and bind have their defaults. See "OPTIONS" in Punk::Plugin::Challenge.

The wire shapes

A puzzle is one string, safe in a header, a query string, a data attribute and JSON without escaping:

v1.<ts>.<bits>.<salt>.<mac>

A solution is the puzzle, a dot, and a decimal nonce such that the SHA-256 of the whole solution string begins with bits zero bits:

v1.<ts>.<bits>.<salt>.<mac>.<nonce>

A clearance is the cookie value:

v1.<exp>.<bits>.<mac>

ts is the issue time and exp the absolute expiry, both epoch seconds. bits is the difficulty, 1 to 22. salt makes two puzzles issued in the same second to the same subject distinct; it is not random, because a puzzle is public the moment it is issued and the MAC is what makes it unforgeable. mac is the first sixteen bytes of an HMAC-SHA256 under the secret, base64url, over the other fields and the subject:

"puzzle\0" . $subject . "\0" . $ts  . "\0" . $bits . "\0" . $salt
"clear\0"  . $subject . "\0" . $exp . "\0" . $bits

Different domain strings, so a puzzle MAC is never a valid clearance MAC over the same fields.

The subject a token is bound to is never written into it. It is an input to the MAC, so a token presented from a different subject fails the MAC and the verifier cannot tell that case from a forgery, which is the point: there is nothing in the token for a client to edit.

The version prefix is there so a later release can change any of it without a flag day: a v1 cookie under a v2 plugin is simply not a clearance, and the client solves once more.

METHODS

key

Thirty-two random bytes as base64url, for the plugin's secret: what punk challenge key prints. Croaks when no entropy source is available rather than returning anything an attacker could predict.

subject($addr, $bind)

The subject an address has under a binding: prefix, the default, is the /24 of an IPv4 address and the /64 of an IPv6 one; ip is the address exactly; none is the empty string. An address that is neither IPv4 nor IPv6 text is used exactly as given.

issue(\%cfg, $subject, %opts)

A fresh puzzle, signed with the first secret. bits overrides the configuration's; now overrides the clock, for a test.

verify(\%cfg, $subject, $puzzle, $nonce, %opts)

The puzzle's own difficulty when the solution is correct for this subject, else undef. $nonce undef means $puzzle already carries it. In list context a second value names which check refused: shape, stale, future, mac, bits or hash; ok when none did.

The checks run cheapest first: the shape, with every field's length capped before anything is computed; freshness, ts within puzzle_ttl of now and not in the future by more than sixty seconds; the MAC, recomputed for this subject against each secret in turn and compared in constant time; bits at least what bits in the options demands, the configuration's when unsaid; then one hash of the solution. Two hashes for a correct solution, one for a wrong one.

clear(\%cfg, $subject, %opts)

A clearance for this subject at bits, expiring ttl from now.

cleared(\%cfg, $subject, $value, %opts)

The clearance's difficulty when it is valid for this subject, unexpired, and at or above bits, else undef. In list context the reason follows: shape, expired, mac or bits. One HMAC and two comparisons. bits is in the clearance so that raising a rule's difficulty invalidates the clearances that were bought cheaper, immediately, with no state.

SEE ALSO

Punk::Plugin::Challenge, Punk::Challenge::Solver.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)