NAME
Dancer2::Session::Pg::Cipher - the authenticated-cipher contract for Dancer2::Session::Pg
VERSION
version 0.001
SYNOPSIS
package My::Cipher::XChaCha20;
use Moo;
with 'Dancer2::Session::Pg::Cipher';
use Crypt::AuthEnc::ChaCha20Poly1305 ();
sub cipher_id { return 200 } # third-party range: 128..255
sub cipher_name { return 'XChaCha20-Poly1305' }
sub key_bytes { return 32 }
sub iv_bytes { return 24 } # a 24-byte nonce, unlike the core four
sub tag_bytes { return 16 }
# $aad is authenticated but NOT encrypted, and must not be dropped: it is
# what binds a sealed payload to the session id it belongs to.
sub seal {
my ( $self, $key, $iv, $plaintext, $aad ) = @_;
return My::XChaCha::seal( $key, $iv, $aad, $plaintext ); # ( $ciphertext, $tag )
}
sub unseal {
my ( $self, $key, $iv, $ciphertext, $tag, $aad ) = @_;
return My::XChaCha::open( $key, $iv, $aad, $ciphertext, $tag ); # $plaintext or undef
}
# and then, in the application, as the alg of a slot
# encryption_keys:
# 0: { key: "...", alg: "AES-256-GCM" } # kept, read only
# 1:
# key: "..."
# alg: "My::Cipher::XChaCha20"
# active: true
DESCRIPTION
Dancer2::Session::Pg stores a cipher identifier in every payload it writes, so the cipher a session was written with is a property of the row rather than of the configuration. That is what makes a cipher replaceable: point alg at a new one and new sessions use it while existing rows stay readable until they expire.
This role is the contract such a cipher implements. Consume it with with rather than duck-typing the methods: that is how "cipher_self_check" arrives, and the engine refuses a cipher that does not provide it.
Only authenticated modes
A session frequently carries the credentials that prove who somebody is. A row that has been altered in the database must fail to decrypt, not deserialise into a structure the application then trusts, so every cipher here produces a tag and verifies it. "cipher_self_check" tests exactly that, by flipping a bit and insisting the result is refused.
Cipher ids are permanent
The id is one byte in every stored payload. Changing a cipher's id makes rows written under the old one unreadable, which logs those users out.
1 .. 127 reserved for ciphers shipped with Dancer2::Session::Pg
128 .. 255 third-party range
The engine croaks at construction if two readable ciphers claim one id, so a collision is a startup failure rather than a payload that decrypts as the wrong thing. Within the third-party range a deployment owns its own collisions.
Your key length is your own business
A cipher declares the key_bytes it needs, and the engine checks the key of the slot your cipher sits in against that cipher and nothing else. Other slots may hold keys of other lengths; it does not concern you.
So there is no equal-key-length restriction to design around. A deployment can rotate from a 16-byte cipher to a 32-byte one in a single step by giving the new pair a slot of its own, which is what "Changing the key length" in Dancer2::Session::Pg describes and what the distribution's own test suite exercises.
What you must not do is assume anything about the key beyond its length. One engine may hold several keys, a given row names the slot that sealed it, and your seal and unseal are handed the key for that slot and never asked to choose.
REQUIRED METHODS
cipher_id
An integer in 1 .. 255, written into every payload. Permanent; see above.
Four are already taken, and they are part of the stored format, so they are not available for reuse:
1 AES-128-GCM (Dancer2::Session::Pg::Cipher::AESGCM, 16-byte key)
2 AES-192-GCM (the same class, 24-byte key)
3 AES-256-GCM (the same class, 32-byte key)
4 ChaCha20-Poly1305 (Dancer2::Session::Pg::Cipher::ChaCha20Poly1305)
Pick from 128 .. 255 for a cipher of your own. 1 .. 127 is reserved for built-ins, used or not, and "cipher_self_check" refuses an id in that range that no built-in has yet taken -- claiming one would work perfectly today and collide with a future built-in, at which point the rows written under it become unreadable.
Claiming one of the four ON PURPOSE is a legitimate thing to do -- a hardware-accelerated, audited or vendored implementation of the same algorithm has to keep the id, or it could not read the rows it is replacing. But it means exactly one thing, this class is a byte-compatible drop-in for that cipher, and "cipher_self_check" enforces it rather than trusting it: a class claiming a reserved id must read a payload the built-in wrote, and the built-in must read one it wrote. Both directions, because both happen -- the engine reads old rows after the swap, and the built-in reads the replacement's rows if it is ever taken out again.
So a NEW cipher that takes a reserved id is refused at construction, with the range to use instead. Without that check it would have worked perfectly on an empty table and then failed to open a single row written before it, the stored header agreeing about the cipher and the tag disagreeing about everything else.
Two ciphers colliding inside 128 .. 255 are a separate matter, and the engine refuses that ring too -- but only when both are configured at once, which is all it can see.
cipher_name
A short string for error messages and algorithms. Not stored.
key_bytes, iv_bytes, tag_bytes
The exact lengths this cipher requires, as integers. The engine validates the configured key against key_bytes, draws iv_bytes from Crypt::PRNG for every write, and uses tag_bytes to find the boundaries when reading a row back, so all three must be constant for a given cipher_id.
It must actually encrypt
"cipher_self_check" compares the ciphertext against the plaintext and refuses a cipher that returns the plaintext unchanged.
This is worth stating because every other check in that method tests authentication: that the tag is real, that altered input is refused, that the additional data is bound in. A cipher that computes a sound MAC over the plaintext and then hands back the plaintext as its ciphertext passes all of them. It round trips, it refuses every forgery -- and it writes sessions to the database in the clear.
That is not a contrived shape. It is what an encrypt-then-MAC implementation becomes if its author returns the wrong variable, and nothing about the result looks wrong from outside.
seal
my ( $ciphertext, $tag ) = $cipher->seal( $key, $iv, $plaintext, $aad );
Encrypts and authenticates. Must return the tag separately, and it must be exactly tag_bytes long.
$aad is additional data that must be authenticated but not encrypted. The engine passes the payload header and the session id, which is what binds a sealed session to the row it belongs to. Pass it to your AEAD primitive -- do not drop it, and do not encrypt it.
unseal
my $plaintext = $cipher->unseal( $key, $iv, $ciphertext, $tag, $aad );
Verifies and decrypts. On failure, return undef or throw -- the engine treats both as "this row is not readable" and the session then looks absent rather than corrupt. Do not return unverified plaintext under any circumstances, and fail when $aad does not match what seal was given: a cipher that accepts the additional data and then ignores it would let a payload sealed for one session id open under another. "cipher_self_check" tests exactly that.
PROVIDED METHODS
cipher_self_check
$cipher->cipher_self_check; # or croaks
Called by Dancer2::Session::Pg when the engine is built, once for every configured slot's cipher -- including the retired ones kept only to read old rows, since a cipher that cannot be trusted to read is no more use than one that cannot be trusted to write. Checks that the declared lengths are plausible, that a known plaintext survives a round trip, that the tag is the advertised length, that altering either the ciphertext or the tag is refused, and that the additional authenticated data is actually authenticated.
It costs microseconds per slot and it runs before the process serves anything.
SEE ALSO
AUTHOR
Mikko Koivunalho <mikko.koivunalho@iki.fi>
LICENSE AND COPYRIGHT
This software is copyright (c) 2026 by Mikko Koivunalho.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.