Skip to content

Commit 1bcbacf

Browse files
authored
Merge pull request #2212 from mojolicious/encrypted_sessions
Add support for encrypted sessions with CryptX
2 parents db81163 + c820715 commit 1bcbacf

File tree

12 files changed

+276
-64
lines changed

12 files changed

+276
-64
lines changed

.github/workflows/linux.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
- name: Install dependencies
3333
run: |
3434
cpanm -n --installdeps .
35-
cpanm -n Cpanel::JSON::XS EV Role::Tiny
35+
cpanm -n Cpanel::JSON::XS EV Role::Tiny CryptX
3636
cpanm -n Test::Pod Test::Pod::Coverage TAP::Formatter::GitHubActions
3737
- name: Run tests
3838
run: prove --merge --formatter TAP::Formatter::GitHubActions -l t t/mojo t/mojolicious

lib/Mojo/Util.pm

+67-7
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ use Symbol qw(delete_package);
2121
use Time::HiRes ();
2222
use Unicode::Normalize ();
2323

24+
# Encryption support requires CryptX 0.080+
25+
use constant CRYPTX => $ENV{MOJO_NO_CRYPTX} ? 0 : !!(eval {
26+
require CryptX;
27+
require Crypt::AuthEnc::ChaCha20Poly1305;
28+
require Crypt::KeyDerivation;
29+
require Crypt::Misc;
30+
require Crypt::PRNG;
31+
CryptX->VERSION('0.080');
32+
1;
33+
});
34+
2435
# Check for monotonic clock support
2536
use constant MONOTONIC => !!eval { Time::HiRes::clock_gettime(Time::HiRes::CLOCK_MONOTONIC()) };
2637

@@ -64,15 +75,15 @@ my $UNQUOTED_VALUE_RE = qr/\G=\s*([^;, ]*)/;
6475
# HTML entities
6576
my $ENTITY_RE = qr/&(?:\#((?:[0-9]{1,7}|x[0-9a-fA-F]{1,6}));|(\w+[;=]?))/;
6677

67-
# Encoding and pattern cache
68-
my (%ENCODING, %PATTERN);
78+
# Encoding, encryption and pattern caches
79+
my (%ENCODING, %ENCRYPTION, %PATTERN);
6980

7081
our @EXPORT_OK = (
71-
qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode deprecated dumper encode),
72-
qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_attr_unescape html_unescape humanize_bytes),
73-
qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare),
74-
qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent),
75-
qw(unquote url_escape url_unescape xml_escape xor_encode)
82+
qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode decrypt_cookie deprecated dumper),
83+
qw(encode encrypt_cookie extract_usage generate_secret getopt gunzip gzip header_params hmac_sha1_sum),
84+
qw(html_attr_unescape html_unescape humanize_bytes md5_bytes md5_sum monkey_patch network_contains punycode_decode),
85+
qw(punycode_encode quote scope_guard secure_compare sha1_bytes sha1_sum slugify split_cookie_header split_header),
86+
qw(steady_time tablify term_escape trim unindent unquote url_escape url_unescape xml_escape xor_encode)
7687
);
7788

7889
# Aliases
@@ -115,6 +126,18 @@ sub decamelize {
115126
} split /::/, $str;
116127
}
117128

129+
sub decrypt_cookie {
130+
my ($value, $key, $salt) = @_;
131+
croak 'CryptX 0.080+ required for encrypted cookie support' unless CRYPTX;
132+
133+
return undef unless $value =~ /^([^-]+)-([^-]+)-([^-]+)$/;
134+
my ($ct, $iv, $tag) = ($1, $2, $3);
135+
($ct, $iv, $tag) = (Crypt::Misc::decode_b64($ct), Crypt::Misc::decode_b64($iv), Crypt::Misc::decode_b64($tag));
136+
137+
my $dk = $ENCRYPTION{$key}{$salt} ||= Crypt::KeyDerivation::pbkdf2($key, $salt);
138+
return Crypt::AuthEnc::ChaCha20Poly1305::chacha20poly1305_decrypt_verify($dk, $iv, '', $ct, $tag);
139+
}
140+
118141
sub decode {
119142
my ($encoding, $bytes) = @_;
120143
return undef unless eval { $bytes = _encoding($encoding)->decode("$bytes", 1); 1 };
@@ -130,6 +153,17 @@ sub dumper { Data::Dumper->new([@_])->Indent(1)->Sortkeys(1)->Terse(1)->Useqq(1)
130153

131154
sub encode { _encoding($_[0])->encode("$_[1]", 0) }
132155

156+
sub encrypt_cookie {
157+
my ($value, $key, $salt) = @_;
158+
croak 'CryptX 0.080+ required for encrypted cookie support' unless CRYPTX;
159+
160+
my $dk = $ENCRYPTION{$key}{$salt} ||= Crypt::KeyDerivation::pbkdf2($key, $salt);
161+
my $iv = Crypt::PRNG::random_bytes(12);
162+
my ($ct, $tag) = Crypt::AuthEnc::ChaCha20Poly1305::chacha20poly1305_encrypt_authenticate($dk, $iv, '', $value);
163+
164+
return join '-', Crypt::Misc::encode_b64($ct), Crypt::Misc::encode_b64($iv), Crypt::Misc::encode_b64($tag);
165+
}
166+
133167
sub extract_usage {
134168
my $file = @_ ? "$_[0]" : (caller)[1];
135169

@@ -141,6 +175,12 @@ sub extract_usage {
141175
return unindent($output);
142176
}
143177

178+
sub generate_secret {
179+
return Crypt::Misc::encode_b64u(Crypt::PRNG::random_bytes(128)) if CRYPTX;
180+
srand;
181+
return sha1_sum($$ . steady_time() . rand);
182+
}
183+
144184
sub getopt {
145185
my ($array, $opts) = map { ref $_[0] eq 'ARRAY' ? shift : $_ } \@ARGV, [];
146186

@@ -634,6 +674,13 @@ Convert C<CamelCase> string to C<snake_case> and replace C<::> with C<->.
634674
635675
Decode bytes to characters with L<Encode>, or return C<undef> if decoding failed.
636676
677+
=head2 decrypt_cookie
678+
679+
my $value = decrypt_cookie $encrypted, 'passw0rd', 'salt';
680+
681+
Decrypt cookie value encrypted with L</encrypt_cookie>, returns the decrypted value or C<undef>. Note that this
682+
function is B<EXPERIMENTAL> and might change without warning!
683+
637684
=head2 deprecated
638685
639686
deprecated 'foo is DEPRECATED in favor of bar';
@@ -653,6 +700,12 @@ Dump a Perl data structure with L<Data::Dumper>.
653700
654701
Encode characters to bytes with L<Encode>.
655702
703+
=head2 encrypt_cookie
704+
705+
my $encrypted = encrypt_cookie $value, 'passw0rd', 'salt';
706+
707+
Encrypt cookie value. Note that this function is B<EXPERIMENTAL> and might change without warning!
708+
656709
=head2 extract_usage
657710
658711
my $usage = extract_usage;
@@ -670,6 +723,13 @@ function was called from.
670723
671724
=cut
672725
726+
=head2 generate_secret
727+
728+
my $secret = generate_secret;
729+
730+
Generate a random secret with a cryptographically secure random number generator if available, and a less secure
731+
fallback if not. Note that this function is B<EXPERIMENTAL> and might change without warning!
732+
673733
=head2 getopt
674734
675735
getopt

lib/Mojolicious.pm

+3
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,9 @@ rotating passphrases, just add new ones to the front and remove old ones from th
513513
Signed cookie based session manager, defaults to a L<Mojolicious::Sessions> object. You can usually leave this alone,
514514
see L<Mojolicious::Controller/"session"> for more information about working with session data.
515515
516+
# Enable encrypted sessions
517+
$app->sessions->encrypted(1);
518+
516519
# Change name of cookie used for all sessions
517520
$app->sessions->cookie_name('mysession');
518521

lib/Mojolicious/Command/Author/generate/app.pm

+2-2
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ done_testing();
196196
</p>
197197
198198
@@ config
199-
% use Mojo::Util qw(sha1_sum steady_time);
199+
% use Mojo::Util qw(generate_secret);
200200
---
201201
secrets:
202-
- <%= sha1_sum $$ . steady_time . rand %>
202+
- <%= generate_secret() %>

lib/Mojolicious/Command/version.pm

+10-7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use Mojo::Base 'Mojolicious::Command';
44
use Mojo::IOLoop::Client;
55
use Mojo::IOLoop::TLS;
66
use Mojo::JSON;
7+
use Mojo::Util;
78
use Mojolicious;
89

910
has description => 'Show versions of available modules';
@@ -12,13 +13,14 @@ has usage => sub { shift->extract_usage };
1213
sub run {
1314
my $self = shift;
1415

15-
my $json = Mojo::JSON->JSON_XS ? $Cpanel::JSON::XS::VERSION : 'n/a';
16-
my $ev = eval { require Mojo::Reactor::EV; 1 } ? $EV::VERSION : 'n/a';
17-
my $socks = Mojo::IOLoop::Client->can_socks ? $IO::Socket::Socks::VERSION : 'n/a';
18-
my $tls = Mojo::IOLoop::TLS->can_tls ? $IO::Socket::SSL::VERSION : 'n/a';
19-
my $nnr = Mojo::IOLoop::Client->can_nnr ? $Net::DNS::Native::VERSION : 'n/a';
20-
my $roles = Mojo::Base->ROLES ? $Role::Tiny::VERSION : 'n/a';
21-
my $async = Mojo::Base->ASYNC ? $Future::AsyncAwait::VERSION : 'n/a';
16+
my $json = Mojo::JSON->JSON_XS ? $Cpanel::JSON::XS::VERSION : 'n/a';
17+
my $cryptx = Mojo::Util->CRYPTX ? $CryptX::VERSION : 'n/a';
18+
my $ev = eval { require Mojo::Reactor::EV; 1 } ? $EV::VERSION : 'n/a';
19+
my $socks = Mojo::IOLoop::Client->can_socks ? $IO::Socket::Socks::VERSION : 'n/a';
20+
my $tls = Mojo::IOLoop::TLS->can_tls ? $IO::Socket::SSL::VERSION : 'n/a';
21+
my $nnr = Mojo::IOLoop::Client->can_nnr ? $Net::DNS::Native::VERSION : 'n/a';
22+
my $roles = Mojo::Base->ROLES ? $Role::Tiny::VERSION : 'n/a';
23+
my $async = Mojo::Base->ASYNC ? $Future::AsyncAwait::VERSION : 'n/a';
2224

2325
print <<EOF;
2426
CORE
@@ -27,6 +29,7 @@ CORE
2729
2830
OPTIONAL
2931
Cpanel::JSON::XS 4.09+ ($json)
32+
CryptX 0.080+ ($cryptx)
3033
EV 4.32+ ($ev)
3134
IO::Socket::Socks 0.64+ ($socks)
3235
IO::Socket::SSL 2.009+ ($tls)

lib/Mojolicious/Controller.pm

+54
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,41 @@ sub cookie {
4949
return $cookie->value;
5050
}
5151

52+
sub encrypted_cookie {
53+
my ($self, $name, $value, $options) = @_;
54+
55+
# Request cookie
56+
return $self->every_encrypted_cookie($name)->[-1] unless defined $value;
57+
58+
# Response cookie
59+
my $app = $self->app;
60+
my $secret = $app->secrets->[0];
61+
my $moniker = $app->moniker;
62+
return $self->cookie($name, Mojo::Util::encrypt_cookie($value, $secret, $moniker), $options);
63+
}
64+
5265
sub every_cookie { [map { $_->value } @{shift->req->every_cookie(shift)}] }
5366

67+
sub every_encrypted_cookie {
68+
my ($self, $name) = @_;
69+
70+
my $app = $self->app;
71+
my $secrets = $app->secrets;
72+
my $moniker = $app->moniker;
73+
my @results;
74+
for my $value (@{$self->every_cookie($name)}) {
75+
my $decrypted;
76+
for my $secret (@$secrets) {
77+
last if defined($decrypted = Mojo::Util::decrypt_cookie($value, $secret, $moniker));
78+
}
79+
if (defined $decrypted) { push @results, $decrypted }
80+
81+
else { $self->helpers->log->trace(qq{Cookie "$name" is not encrypted}) }
82+
}
83+
84+
return \@results;
85+
}
86+
5487
sub every_param {
5588
my ($self, $name) = @_;
5689

@@ -399,6 +432,17 @@ you want to access more than just the last one, you can use L</"every_cookie">.
399432
# Create secure response cookie
400433
$c->cookie(secret => 'I <3 Mojolicious', {secure => 1, httponly => 1});
401434
435+
=head2 encrypted_cookie
436+
437+
my $value = $c->encrypted_cookie('foo');
438+
$c = $c->encrypted_cookie(foo => 'bar');
439+
$c = $c->encrypted_cookie(foo => 'bar', {path => '/'});
440+
441+
Access encrypted request cookie values and create new encrypted response cookies. If there are multiple values sharing
442+
the same name, and you want to access more than just the last one, you can use L</"every_encrypted_cookie">. Cookies
443+
are encrypted with ChaCha20-Poly1305, to prevent tampering, and the ones failing decryption will be automatically
444+
discarded. Note that this method is B<EXPERIMENTAL> and might change without warning!
445+
402446
=head2 every_cookie
403447
404448
my $values = $c->every_cookie('foo');
@@ -408,6 +452,16 @@ Similar to L</"cookie">, but returns all request cookie values sharing the same
408452
$ Get first cookie value
409453
my $first = $c->every_cookie('foo')->[0];
410454
455+
=head2 every_encrypted_cookie
456+
457+
my $values = $c->every_encrypted_cookie('foo');
458+
459+
Similar to L</"encrypted_cookie">, but returns all encrypted request cookie values sharing the same name as an array
460+
reference. Note that this method is B<EXPERIMENTAL> and might change without warning!
461+
462+
# Get first encrypted cookie value
463+
my $first = $c->every_encrypted_cookie('foo')->[0];
464+
411465
=head2 every_param
412466
413467
my $values = $c->every_param('foo');

lib/Mojolicious/Guides/FAQ.pod

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ frameworks, it is more of a web toolkit and can even be used as the foundation f
2727
We are optimizing L<Mojolicious> for user-friendliness and development speed, without compromises. While there are no
2828
rules in L<Mojolicious::Guides::Contributing> that forbid dependencies, we do currently discourage adding non-optional
2929
ones in favor of a faster and more painless installation process. And we do in fact already use several optional CPAN
30-
modules such as L<Cpanel::JSON::XS>, L<EV>, L<IO::Socket::Socks>, L<IO::Socket::SSL>, L<Net::DNS::Native>, L<Plack> and
31-
L<Role::Tiny> to provide advanced functionality if possible.
30+
modules such as L<Cpanel::JSON::XS>, L<CryptX>, L<EV>, L<IO::Socket::Socks>, L<IO::Socket::SSL>, L<Net::DNS::Native>,
31+
L<Plack> and L<Role::Tiny> to provide advanced functionality if possible.
3232

3333
=head2 Why reinvent wheels?
3434

lib/Mojolicious/Guides/Growing.pod

+2-2
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ web server in the form of cookies.
104104
Set-Cookie: session=hmac-sha256(base64(json($session)))
105105

106106
In L<Mojolicious> however we are taking this concept one step further by storing everything JSON serialized and Base64
107-
encoded in HMAC-SHA256 signed cookies, which is more compatible with the REST philosophy and reduces infrastructure
108-
requirements.
107+
encoded in HMAC-SHA256 signed, or ChaCha20-Poly1305 encrypted cookies, which is more compatible with the REST
108+
philosophy and reduces infrastructure requirements.
109109

110110
=head2 Test-Driven Development
111111

lib/Mojolicious/Sessions.pm

+15-8
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use Mojo::Base -base;
44
use Mojo::JSON;
55
use Mojo::Util qw(b64_decode b64_encode);
66

7-
has [qw(cookie_domain secure)];
7+
has [qw(cookie_domain encrypted secure)];
88
has cookie_name => 'mojolicious';
99
has cookie_path => '/';
1010
has default_expiration => 3600;
@@ -15,7 +15,8 @@ has serialize => sub { \&_serialize };
1515
sub load {
1616
my ($self, $c) = @_;
1717

18-
return unless my $value = $c->signed_cookie($self->cookie_name);
18+
my $method = $self->encrypted ? 'encrypted_cookie' : 'signed_cookie';
19+
return unless my $value = $c->$method($self->cookie_name);
1920
$value =~ y/-/=/;
2021
return unless my $session = $self->deserialize->(b64_decode $value);
2122

@@ -58,16 +59,14 @@ sub store {
5859
samesite => $self->samesite,
5960
secure => $self->secure
6061
};
61-
$c->signed_cookie($self->cookie_name, $value, $options);
62+
my $method = $self->encrypted ? 'encrypted_cookie' : 'signed_cookie';
63+
$c->$method($self->cookie_name, $value, $options);
6264
}
6365

66+
# DEPRECATED! (Remove once old sessions with padding are no longer a concern)
6467
sub _deserialize { Mojo::JSON::decode_json($_[0] =~ s/\}\KZ*$//r) }
6568

66-
sub _serialize {
67-
no warnings 'numeric';
68-
my $out = Mojo::JSON::encode_json($_[0]);
69-
return $out . 'Z' x (1025 - length $out);
70-
}
69+
sub _serialize { Mojo::JSON::encode_json($_[0]) }
7170

7271
1;
7372

@@ -143,6 +142,14 @@ A callback used to deserialize sessions, defaults to L<Mojo::JSON/"j">.
143142
144143
$sessions->deserialize(sub ($bytes) { return {} });
145144
145+
=head2 encrypted
146+
147+
my $bool = $sessions->encrypted;
148+
$sessions = $sessions->encrypted($bool);
149+
150+
Use encrypted session cookies instead of merely cryptographically signed ones. Note that this attribute is
151+
B<EXPERIMENTAL> and might change without warning!
152+
146153
=head2 samesite
147154
148155
my $samesite = $sessions->samesite;

t/mojo/util.t

+26-5
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ use Mojo::ByteStream qw(b);
88
use Mojo::DeprecationTest;
99
use Sub::Util qw(subname);
1010

11-
use Mojo::Util qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode dumper encode),
12-
qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_unescape html_attr_unescape humanize_bytes),
13-
qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare),
14-
qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent),
15-
qw(unquote url_escape url_unescape xml_escape xor_encode);
11+
use Mojo::Util qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode decrypt_cookie dumper),
12+
qw(encode encrypt_cookie extract_usage generate_secret getopt gunzip gzip header_params hmac_sha1_sum html_unescape),
13+
qw(html_attr_unescape humanize_bytes md5_bytes md5_sum monkey_patch network_contains punycode_decode),
14+
qw(punycode_encode quote scope_guard secure_compare sha1_bytes sha1_sum slugify split_cookie_header split_header),
15+
qw(steady_time tablify term_escape trim unindent unquote url_escape url_unescape xml_escape xor_encode);
1616

1717
subtest 'camelize' => sub {
1818
is camelize('foo_bar_baz'), 'FooBarBaz', 'right camelized result';
@@ -656,6 +656,27 @@ subtest 'humanize_bytes' => sub {
656656
is humanize_bytes( 245760), '240KiB', 'less than a MiB';
657657
};
658658

659+
subtest 'encrypt_cookie/decrypt_cookie' => sub {
660+
plan skip_all => 'CryptX required!' unless Mojo::Util->CRYPTX;
661+
662+
subtest 'Roundtrip' => sub {
663+
my $encrypted = encrypt_cookie('test', 'foo', 'salt');
664+
isnt $encrypted, 'test', 'encrypted';
665+
is decrypt_cookie($encrypted, 'foo', 'salt'), 'test', 'decrypted';
666+
};
667+
668+
is decrypt_cookie('test', 'foo', 'salt'), undef, 'not encrypted';
669+
is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaW==', 'foo', 'salt'), undef, 'wrong tag';
670+
is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTm-v7Qm+Xgoi1t94GLSHYGkaw==', 'foo', 'salt'), undef, 'wrong random bytes';
671+
is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaw==', 'bar', 'salt'), undef, 'wrong password';
672+
is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaw==', 'foo', 'bar'), undef, 'wrong salt';
673+
is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaw==', 'foo', 'salt'), 'test', 'decrypted';
674+
};
675+
676+
subtest 'generate_secret' => sub {
677+
like generate_secret, qr/^[A-Za-z0-9_-]{32,}$/, 'right format';
678+
};
679+
659680
subtest 'Hide DATA usage from error messages' => sub {
660681
eval { die 'whatever' };
661682
unlike $@, qr/DATA/, 'DATA has been hidden';

0 commit comments

Comments
 (0)