File Coverage

blib/lib/PAGI/Middleware/Session/Store/Cookie.pm
Criterion Covered Total %
statement 63 66 95.4
branch 10 12 83.3
condition 4 6 66.6
subroutine 14 14 100.0
pod 4 4 100.0
total 95 102 93.1


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Session::Store::Cookie;
2              
3 1     1   269456 use strict;
  1         2  
  1         33  
4 1     1   5 use warnings;
  1         2  
  1         66  
5              
6             our $VERSION = '0.001003';
7              
8 1     1   861 use parent 'PAGI::Middleware::Session::Store';
  1         452  
  1         5  
9 1     1   851 use Future;
  1         4  
  1         22  
10 1     1   449 use JSON::MaybeXS qw(encode_json decode_json);
  1         18801  
  1         134  
11 1     1   1924 use MIME::Base64 qw(encode_base64 decode_base64);
  1         1336  
  1         138  
12 1     1   802 use Digest::SHA qw(sha256);
  1         6033  
  1         1359  
13              
14             =head1 NAME
15              
16             PAGI::Middleware::Session::Store::Cookie - Encrypted client-side session store
17              
18             =head1 SYNOPSIS
19              
20             use PAGI::Middleware::Session::Store::Cookie;
21              
22             my $store = PAGI::Middleware::Session::Store::Cookie->new(
23             secret => 'at-least-32-bytes-of-secret-key!',
24             );
25              
26             =head1 DESCRIPTION
27              
28             Stores session data encrypted in the client cookie itself using AES-256-GCM
29             (authenticated encryption). No server-side storage is needed.
30              
31             The C method returns the encrypted blob (not the session ID).
32             The C method accepts the encrypted blob and returns the decoded
33             session data, or undef if decryption/verification fails.
34              
35             B Cookie size is limited to ~4KB. Large sessions will fail.
36             Session revocation requires server-side state (e.g., a blocklist).
37              
38             B This module will be extracted to a separate CPAN distribution
39             in a future release.
40              
41             =cut
42              
43             sub new {
44 12     12 1 346665 my ($class, %args) = @_;
45 12 100       57 die "Store::Cookie requires 'secret'" unless $args{secret};
46              
47 11         71 my $self = $class->SUPER::new(%args);
48              
49             # Derive a 32-byte key from the secret via SHA-256
50 11         185 $self->{_key} = sha256($self->{secret});
51              
52 11         41 return $self;
53             }
54              
55             =head1 METHODS
56              
57             =head2 new
58              
59             my $store = PAGI::Middleware::Session::Store::Cookie->new(
60             secret => 'at-least-32-bytes-of-secret-key!',
61             );
62              
63             Creates a new cookie session store. The C parameter is required
64             and is used to derive the AES-256 encryption key via SHA-256.
65              
66             =head2 get
67              
68             my $data = await $store->get($encrypted_blob);
69              
70             Decrypts and decodes the blob. Returns a Future resolving to the session
71             hashref, or undef if the blob is invalid, tampered, or cannot be decoded.
72              
73             =cut
74              
75             sub get {
76 7     7 1 363 my ($self, $blob) = @_;
77              
78 7 100 66     52 return Future->done(undef) unless defined $blob && length $blob;
79              
80 6         12 my $data = eval { $self->_decrypt($blob) };
  6         24  
81 6         35 return Future->done($data);
82             }
83              
84             =head2 set
85              
86             my $transport_value = await $store->set($id, $data);
87              
88             Encrypts the session data and returns a Future resolving to the encrypted
89             blob. This blob is what gets passed to C for storage
90             in the response cookie. Nothing is stored server-side.
91              
92             =cut
93              
94             sub set {
95 8     8 1 628 my ($self, $id, $data) = @_;
96              
97 8         62 my $blob = $self->_encrypt($data);
98 8         73 return Future->done($blob);
99             }
100              
101             =head2 delete
102              
103             await $store->delete($id);
104              
105             No-op for cookie stores (client manages cookie lifetime).
106             Returns a Future resolving to 1.
107              
108             =cut
109              
110             sub delete {
111 1     1 1 39 my ($self, $id) = @_;
112 1         6 return Future->done(1);
113             }
114              
115             sub _encrypt {
116 8     8   22 my ($self, $data) = @_;
117              
118 8         1006 require Crypt::AuthEnc::GCM;
119              
120 8         6135 my $json = encode_json($data);
121              
122             # Generate random 12-byte IV (standard for AES-GCM)
123 8         29 my $iv = _random_bytes(12);
124              
125 8         2539 my $gcm = Crypt::AuthEnc::GCM->new('AES', $self->{_key}, $iv);
126 8         107 my $ciphertext = $gcm->encrypt_add($json);
127 8         57 my $tag = $gcm->encrypt_done;
128              
129             # Pack: iv (12) + tag (16) + ciphertext
130 8         29 my $packed = $iv . $tag . $ciphertext;
131 8         116 return encode_base64($packed, '');
132             }
133              
134             sub _decrypt {
135 6     6   17 my ($self, $blob) = @_;
136              
137 6         44 require Crypt::AuthEnc::GCM;
138              
139 6         26 my $packed = decode_base64($blob);
140 6 100 66     37 return undef unless defined $packed && length($packed) > 28;
141              
142             # Unpack: iv (12) + tag (16) + ciphertext
143 5         13 my $iv = substr($packed, 0, 12);
144 5         15 my $tag = substr($packed, 12, 16);
145 5         12 my $ciphertext = substr($packed, 28);
146              
147 5         1322 my $gcm = Crypt::AuthEnc::GCM->new('AES', $self->{_key}, $iv);
148 5         42 my $json = $gcm->decrypt_add($ciphertext);
149 5         56 my $valid = $gcm->decrypt_done($tag);
150              
151 5 100       29 return undef unless $valid;
152              
153 3         98 return decode_json($json);
154             }
155              
156             sub _random_bytes {
157 8     8   21 my ($n) = @_;
158              
159             # Use /dev/urandom for cryptographically secure random bytes.
160             # Falls back to Perl's rand() if /dev/urandom is unavailable
161             # (e.g., on some non-Unix systems). See SECURITY section for details.
162 8 50       476 if (open my $fh, '<:raw', '/dev/urandom') {
163 8         42 my $bytes;
164 8 50       594 read($fh, $bytes, $n) == $n or die "Short read from /dev/urandom";
165 8         93 close $fh;
166 8         63 return $bytes;
167             }
168              
169 0           warn "PAGI::Middleware::Session::Store::Cookie: /dev/urandom not available, "
170             . "falling back to Perl's rand() for IV generation. "
171             . "Install Crypt::URandom for secure random bytes on this platform.\n";
172 0           return join('', map { chr(int(rand(256))) } 1 .. $n);
  0            
173             }
174              
175             1;
176              
177             __END__