PVE::Ticket - new helper class to create auth tickets
[pve-common.git] / src / PVE / Ticket.pm
1 package PVE::Ticket;
2
3 use strict;
4 use warnings;
5
6 use Crypt::OpenSSL::Random;
7 use Crypt::OpenSSL::RSA;
8 use MIME::Base64;
9 use MIME::Base32; #libmime-base32-perl
10 use Digest::SHA;
11 use Time::HiRes qw(gettimeofday);
12
13 use PVE::Exception qw(raise_perm_exc);
14
15 Crypt::OpenSSL::RSA->import_random_seed();
16
17 sub assemble_csrf_prevention_token {
18     my ($secret, $username) = @_;
19
20     my $timestamp = sprintf("%08X", time());
21
22     my $digest = Digest::SHA::sha1_base64("$timestamp:$username", $secret);
23
24     return "$timestamp:$digest";
25 }
26
27 sub verify_csrf_prevention_token {
28     my ($secret, $username, $token, $min_age, $max_age, $noerr) = @_;
29
30     if ($token =~ m/^([A-Z0-9]{8}):(\S+)$/) {
31         my $sig = $2;
32         my $timestamp = $1;
33         my $ttime = hex($timestamp);
34
35         my $digest = Digest::SHA::sha1_base64("$timestamp:$username", $secret);
36
37         my $age = time() - $ttime;
38         return 1 if ($digest eq $sig) && ($age > $min_age) &&
39             ($age < $max_age);
40     }
41
42     raise_perm_exc("Permission denied - invalid csrf token") if !$noerr;
43
44     return undef;
45 }
46
47 # Note: data may not contain white spaces (verify fails in that case)
48 sub assemble_rsa_ticket {
49     my ($rsa_priv, $prefix, $data, $secret_data) = @_;
50
51     my $timestamp = sprintf("%08X", time());
52
53     my $plain = "$prefix:";
54
55     $plain .= "$data:" if defined($data);
56
57     $plain .= $timestamp;
58
59     my $full = defined($secret_data) ? "$plain:$secret_data" : $plain;
60
61     my $ticket = $plain . "::" . encode_base64($rsa_priv->sign($full), '');
62
63     return $ticket;
64 }
65
66 sub verify_rsa_ticket {
67     my ($rsa_pub, $prefix, $ticket, $secret_data, $min_age, $max_age, $noerr) = @_;
68
69     if ($ticket && $ticket =~ m/^(\Q$prefix\E:\S+)::([^:\s]+)$/) {
70         my $plain = $1;
71         my $sig = $2;
72
73         my $full = defined($secret_data) ? "$plain:$secret_data" : $plain;
74
75         if ($rsa_pub->verify($full, decode_base64($sig))) {
76             if ($plain =~ m/^\Q$prefix\E:(?:(\S+):)?([A-Z0-9]{8})$/) {
77                 my $data = $1; # Note: not all tickets contains data
78                 my $timestamp = $2;
79                 my $ttime = hex($timestamp);
80
81                 my $age = time() - $ttime;
82
83                 if (($age > $min_age) && ($age < $max_age)) {
84                     if (defined($data)) {
85                         return wantarray ? ($data, $age) : $data;
86                     } else {
87                         return wantarray ? (1, $age) : 1;
88                     }
89                 }
90             }
91         }
92     }
93
94     raise_perm_exc("permission denied - invalid $prefix ticket") if !$noerr;
95
96     return undef;
97 }
98
99 sub assemble_spice_ticket {
100     my ($secret, $username, $vmid, $node) = @_;
101
102     my ($seconds, $microseconds) = gettimeofday;
103
104     my $timestamp = sprintf("%08x", $seconds);
105
106     my $randomstr = "PVESPICE:$timestamp:$username:$vmid:$node:$secret:" .
107         ':' . sprintf("%08x", $microseconds) .
108         ':' . sprintf("%08x", $$) .
109         ':' . rand(1);
110
111     # this should be used as one-time password
112     # max length is 60 chars (spice limit)
113     # we pass this to qemu set_pasword and limit lifetime there
114     # keep this secret
115     my $ticket = Digest::SHA::sha1_hex($randomstr);
116
117     # Note: spice proxy connects with HTTP, so $proxyticket is exposed to public
118     # we use a signature/timestamp to make sure nobody can fake such a ticket
119     # an attacker can use this $proxyticket, but he will fail because $ticket is
120     # private.
121     # The proxy needs to be able to extract/verify the ticket
122     # Note: data needs to be lower case only, because virt-viewer needs that
123     # Note: RSA signature are too long (>=256 charaters) and make problems with remote-viewer
124
125     my $plain = "pvespiceproxy:$timestamp:$vmid:" . lc($node);
126
127     # produces 40 characters
128     my $sig = unpack("H*", Digest::SHA::sha1($plain, $secret));
129
130     #my $sig =  unpack("H*", $rsa_priv->sign($plain)); # this produce too long strings (512)
131
132     my $proxyticket = "$plain::$sig";
133
134     return ($ticket, $proxyticket);
135 }
136
137 sub verify_spice_connect_url {
138     my ($secret, $connect_str) = @_;
139
140     # Note: we pass the spice ticket as 'host', so the
141     # spice viewer connects with "$ticket:$port"
142
143     return undef if !$connect_str;
144
145     if ($connect_str =~m/^pvespiceproxy:([a-z0-9]{8}):(\d+):(\S+)::([a-z0-9]{40}):(\d+)$/) {
146         my ($timestamp, $vmid, $node, $hexsig, $port) = ($1, $2, $3, $4, $5, $6);
147         my $ttime = hex($timestamp);
148         my $age = time() - $ttime;
149
150         # use very limited lifetime - is this enough?
151         return undef if !(($age > -20) && ($age < 40));
152
153         my $plain = "pvespiceproxy:$timestamp:$vmid:$node";
154         my $sig = unpack("H*", Digest::SHA::sha1($plain, $secret));
155
156         if ($sig eq $hexsig) {
157             return ($vmid, $node, $port);
158         }
159     }
160
161     return undef;
162 }
163
164 1;