File Coverage

blib/lib/SignalWire/Agents/Security/SessionManager.pm
Criterion Covered Total %
statement 90 92 97.8
branch 26 38 68.4
condition 12 26 46.1
subroutine 20 20 100.0
pod 0 10 0.0
total 148 186 79.5


line stmt bran cond sub pod time code
1             package SignalWire::Agents::Security::SessionManager;
2 36     36   149388 use strict;
  36         113  
  36         1614  
3 36     36   194 use warnings;
  36         120  
  36         2249  
4 36     36   685 use Moo;
  36         5962  
  36         274  
5 36     36   16657 use JSON ();
  36         83  
  36         1396  
6 36     36   687 use Digest::SHA qw(hmac_sha256_hex);
  36         4124  
  36         2618  
7 36     36   584 use MIME::Base64 ();
  36         684  
  36         968  
8 36     36   232 use Time::HiRes ();
  36         396  
  36         51858  
9              
10             has 'token_expiry_secs' => (
11             is => 'ro',
12             default => sub { 900 }, # 15 minutes
13             );
14              
15             has 'secret_key' => (
16             is => 'ro',
17             default => sub { _random_hex(32) },
18             );
19              
20             has '_debug_mode' => (
21             is => 'rw',
22             default => sub { 0 },
23             );
24              
25             sub _random_hex {
26 371     371   911 my ($len) = @_;
27             # Use /dev/urandom for cryptographically secure random bytes.
28             # Die on failure rather than falling back to weak randomness.
29 371 50       24009 if (open my $fh, '<:raw', '/dev/urandom') {
30 371         1126 my $bytes;
31 371         29836 my $read = read($fh, $bytes, $len);
32 371         5178 close $fh;
33 371 50 33     2521 if (defined $read && $read == $len) {
34 371         14452 return unpack('H*', $bytes);
35             }
36             }
37 0         0 die "FATAL: Cannot generate secure random bytes - /dev/urandom unavailable. "
38             . "This is required for session security.\n";
39             }
40              
41             sub _random_urlsafe {
42 1     1   2 my ($len) = @_;
43 1         1 my $bytes = '';
44 1         3 for (1 .. $len) {
45 16         66 $bytes .= chr(int(rand(256)));
46             }
47 1         5 return MIME::Base64::encode_base64url($bytes, '');
48             }
49              
50             sub create_session {
51 2     2 0 1650 my ($self, $call_id) = @_;
52 2   66     9 $call_id //= _random_urlsafe(16);
53 2         28 return $call_id;
54             }
55              
56             sub generate_token {
57 6     6 0 41 my ($self, $function_name, $call_id) = @_;
58 6         28 my $expiry = int(time()) + $self->token_expiry_secs;
59 6         21 my $nonce = _random_hex(8);
60              
61 6         19 my $message = "$call_id:$function_name:$expiry:$nonce";
62 6         155 my $signature = hmac_sha256_hex($message, $self->secret_key);
63              
64 6         26 my $token = "$call_id.$function_name.$expiry.$nonce.$signature";
65 6         27 return MIME::Base64::encode_base64url($token, '');
66             }
67              
68             # Alias
69             sub create_tool_token {
70 1     1 0 3 my ($self, $function_name, $call_id) = @_;
71 1         4 return $self->generate_token($function_name, $call_id);
72             }
73              
74             sub _timing_safe_compare {
75 23     23   7384 my ($a, $b) = @_;
76             # Compare HMAC of both values for constant-time comparison
77 23         38 my $key = 'timing-safe-token-comparison';
78 23         250 my $hmac_a = hmac_sha256_hex($a, $key);
79 23         173 my $hmac_b = hmac_sha256_hex($b, $key);
80 23         101 return $hmac_a eq $hmac_b;
81             }
82              
83             sub validate_token {
84 12     12 0 1000874 my ($self, $call_id, $function_name, $token) = @_;
85              
86 12 100 66     79 return 0 unless $call_id && $function_name && $token;
      100        
87              
88 9         12 my $decoded;
89 9         14 eval {
90 9         27 $decoded = MIME::Base64::decode_base64url($token);
91             };
92 9 50 33     123 return 0 if $@ || !$decoded;
93              
94 9         34 my @parts = split(/\./, $decoded);
95 9 100       25 return 0 unless @parts == 5;
96              
97 8         23 my ($token_call_id, $token_function, $token_expiry, $token_nonce, $token_signature) = @parts;
98              
99             # Verify function matches
100 8 100       18 return 0 unless _timing_safe_compare($token_function, $function_name);
101              
102             # Check expiry
103 7         11 my $expiry = eval { int($token_expiry) };
  7         20  
104 7 50 33     25 return 0 if $@ || !defined $expiry;
105 7 100       29 return 0 if $expiry < time();
106              
107             # Recreate and verify signature
108 6         14 my $message = "$token_call_id:$token_function:$token_expiry:$token_nonce";
109 6         45 my $expected_signature = hmac_sha256_hex($message, $self->secret_key);
110 6 100       12 return 0 unless _timing_safe_compare($token_signature, $expected_signature);
111              
112             # Verify call_id
113 5 100       9 return 0 unless _timing_safe_compare($token_call_id, $call_id);
114              
115 4         24 return 1;
116             }
117              
118             # Alias with different parameter order for backward compat
119             sub validate_tool_token {
120 1     1 0 3 my ($self, $function_name, $token, $call_id) = @_;
121 1         3 return $self->validate_token($call_id, $function_name, $token);
122             }
123              
124             # Legacy methods - no-ops for API compat
125 1     1 0 12 sub activate_session { return 1 }
126 1     1 0 5 sub end_session { return 1 }
127 1     1 0 8 sub get_session_metadata { return {} }
128 1     1 0 6 sub set_session_metadata { return 1 }
129              
130             sub debug_token {
131 3     3 0 5579 my ($self, $token) = @_;
132 3 100       23 return { error => 'debug mode not enabled' } unless $self->_debug_mode;
133              
134 2         5 my $decoded;
135 2         3 eval { $decoded = MIME::Base64::decode_base64url($token) };
  2         10  
136 2 50 33     38 if ($@ || !$decoded) {
137             return {
138 0 0 0     0 valid_format => JSON::false,
139             error => $@ // 'decode failed',
140             token_length => defined $token ? length($token) : 0,
141             };
142             }
143              
144 2         9 my @parts = split(/\./, $decoded);
145 2 100       7 if (@parts != 5) {
146             return {
147 1         7 valid_format => JSON::false,
148             parts_count => scalar @parts,
149             token_length => length($token),
150             };
151             }
152              
153 1         5 my ($tc, $tf, $te, $tn, $ts) = @parts;
154 1         3 my $current = int(time());
155 1         3 my $expiry = eval { int($te) };
  1         5  
156 1 50       6 my $expired = defined $expiry ? ($expiry < $current ? 1 : 0) : undef;
    50          
157              
158             return {
159 1 50 33     9 valid_format => JSON::true,
    50          
    50          
160             components => {
161             call_id => (length($tc) > 8 ? substr($tc, 0, 8) . '...' : $tc),
162             function => $tf,
163             expiry => $te,
164             nonce => $tn,
165             signature => (length($ts) > 8 ? substr($ts, 0, 8) . '...' : $ts),
166             },
167             status => {
168             current_time => $current,
169             is_expired => $expired,
170             expires_in_seconds => (defined $expiry && !$expired ? $expiry - $current : 0),
171             },
172             };
173             }
174              
175             1;