Security Advisories (2)
CVE-2026-78619 (2026-08-25)

Punk::Plugin::TOTP versions before 0.05 for Perl accept another account's recovery code at the two-factor challenge because totp_use_recovery compares user identifiers numerically. The helper searches the recovery model for the submitted code's digest alone, across every user's rows, so the ownership test that follows is the only thing binding a code to the account it was issued to. That test compares the row's user_id with the challenged user's id through Perl's integer coercion, and an identifier with no leading digits coerces to zero, so any two of them compare equal. User models keyed on a username, an email address or a UUID hit that case, and a numeric key compares as intended. The challenge route feeds a submitted value to the helper once TOTP verification fails, so an attacker who knows a victim's password and holds a recovery code of their own passes the victim's second factor.

CVE-2026-78655 (2026-08-25)

Punk::Plugin::TOTP versions before 0.05 for Perl allow the second-factor attempt limit to be reset by replaying an earlier session cookie because the challenge route counts failures in the session. The POST handler on challenge_path keeps the failure count as tries inside the totp_pending record in the session, raising it on each rejected code and deleting the pending record once it reaches attempts, five by default. Punk::Session carries the session in a signed cookie unless the application declares a store, and keeps no server-side record, so an earlier value of the same session stays valid until the expiry stamped inside it. A client that saves the cookie before its failed attempts and presents it again gets the pending record back with its counter, and the limit never fires. The replayed record is accepted while its own expiry, pending_ttl seconds from the challenge and 300 by default, has not passed. Sessions declared with a store are not affected: the pending record and its counter then live server-side. The attempt limit does not bound guessing of the second factor, which is left to the per-address rate limit the plugin registers on the same path, 30 requests per 60 seconds.

NAME

Punk::Plugin::TOTP - a second factor for Punk applications

SYNOPSIS

use Punk;

session secret => secret('session_key');
auth model => 'User';
plugin 'TOTP' => { issuer => 'openapi-proxy.com' };

# enrolment
post '/account/2fa' => sub {
    my ($c) = @_;
    my $secret = $c->totp_secret;
    $c->session->{totp_enrolling} = $secret;
    return $c->render('account/2fa',
        { qr => $c->totp_qr($secret) });
};

# the challenge
post '/login/totp' => sub {
    my ($c) = @_;
    my $user = ...;
    return $c->render('auth/totp', { error => 'Try again.' })
        unless $c->totp_verify($user, $c->param('code'));
    ...
};

DESCRIPTION

Wires Punk::TOTP into a Punk application: secret generation, the enrolment QR, and verification with the counter write-back that makes replay protection hold. The engine is usable without this plugin; the plugin exists so every application does not re-derive the wiring that is easy to get wrong.

CONFIG

plugin 'TOTP' => {
    issuer    => 'openapi-proxy.com',   # required
    algorithm => 'sha1',                # sha1 | sha256 | sha512
    digits    => 6,
    period    => 30,
    skew      => 1,
    model     => 'User',
    render    => sub { ... }, # render the challenge form can also be a context method name 
    fields    => {
        secret  => 'totp_secret',
        counter => 'totp_last_counter',
        enabled => 'totp_enabled',
        email   => 'email',
    },
};

fields maps the plugin onto the application's own column names. Unknown options croak at boot.

HELPERS

$c->totp_secret

A fresh secret, base32, sized for the configured algorithm.

$c->totp_uri($secret, %opt)

The otpauth:// URI, issuer from config, account from %opt or the signed-in user's email field.

$c->totp_qr($secret, %opt)

The URI as an SVG through QR::Code, at ECC level H - an enrolment QR is scanned once, from a screen, by every phone the user will ever own, so it gets maximum margin and no logo unless %opt asks. %opt otherwise passes through to QR::Code->svg. An application that renders the QR some other way builds the otpauth:// URI with totp_uri instead.

$c->totp_verify($user, $code)

Verifies against the user's stored secret and, on success, writes the matched counter back through the configured model before returning true - the write is the replay protection. An account with no secret burns the same verification work and returns false, so response time does not report who has 2FA enabled.

$c->totp_recovery_codes($user, %opt)

my $codes = $c->totp_recovery_codes($user);          # ten
my $codes = $c->totp_recovery_codes($user, count => 5);

A fresh set of single-use recovery codes, returned in plaintext exactly once - grouped base32 in an alphabet with no confusable digits. Issuing a new set revokes the old one entirely: partial invalidation is how a drawer ends up holding codes that half-work. Rows go through recovery_model (default Token) in the token table's shape - user_id, kind totp_recovery, a lowercase SHA-256 hex digest, expires 0 for never - so they share a production tokens table untouched.

This is deliberately not $c->issue_token: that keyword deletes older same-kind tokens first (ten codes would collapse to one) and refuses a non-positive ttl (a code in a drawer must not expire). Both behaviours are right for mailed links; recovery codes get their own path rather than weakening them.

$c->totp_use_recovery($user, $code)

Spends a code: true once, false forever after. Case and grouping fold before the digest, so what the paper shows is what works. The row is deleted BEFORE the code is judged - a probe with the wrong user or the wrong kind burns the row it hit rather than leaving it live for its real endpoint, the same discipline take_token documents.

THE AUTH SEAM

A half-authenticated session writes totp_pending into the session and not the auth session key. That is the whole mechanism: Punk's auth battery reads exactly one key, so a session that has passed a password but not a code has no identity as far as every existing auth_guard is concerned. Nothing was taught about 2FA; nobody is signed in yet; there is no new enforcement path to get wrong.

$c->totp_challenge($user, %opt)

Writes the pending marker (user id, an expiry pending_ttl seconds out, the destination from to =>) and returns the redirect to the challenge route. Call it from the sign-in chokepoint - the place every authentication path funnels through - INSTEAD of $c->login when the user has the factor enabled. Gating only the password form leaves the mailed-link sign-ins (verify, reset, invite) as a bypass.

$c->totp_complete

Deletes the marker, signs the user in, records totp_at in the session, calls session_rotate where Punk provides it (0.26+; with a session store that closes a real fixation window), and returns the stored destination. The challenge route calls this for you.

The challenge route

Mounted at challenge_path (default /login/totp). GET renders - a self-contained form by default, or whatever render => names (a coderef or context method, called with the context and an error flag). POST verifies the submitted code as a TOTP code or a recovery code, promotes on success, and clears the pending state entirely after attempts failures (default 5): a limit that only delays is a limit a patient script waits out; this one un-answers the first factor. A per-address rate_limit (30/minute) sits on top.

An application with the csrf keyword enabled must pass its own render that includes the token, or exempt the path; the default form carries none.

totp_guard

my $admin = under '/admin' => totp_guard();
$admin->get('/x' => ...);

The step-up guard, installed as a keyword at registration (so it is called with parens). It passes when this session recorded totp_at - that is, when THIS login satisfied the factor - and otherwise writes a pending marker for the signed-in user and redirects to the challenge (or to login_path when nobody is signed in). It sits inside or after auth_guard, which establishes identity; totp_guard asks a different question.

Note the test-kit gap, deliberately: Punk::Test::login_as seals the auth key directly and sails past the factor - existing suites keep passing, which is correct and worth knowing. totp_guard is not fooled, because totp_at is absent; a green suite is only evidence of enforcement where the step-up guard stands.

STORAGE

Three columns on the user row, named by fields: the secret (text, and it cannot be hashed - whoever reads it can mint codes), the last accepted counter (the replay floor; one write per successful verification), and the enabled flag the application flips only after a first successful verification - enrolment is not complete until one code proves the phone has the secret.

AUTHOR

LNATION, <email at lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)