mirror of
https://git.proxmox.com/git/pve-access-control
synced 2025-07-27 06:51:31 +00:00
fix #2079: add periodic auth key rotation
and modify checks to accept still valid tickets generated using the previous auth key. the slightly complicated caching mechanism is needed for reading the key and its modification timestamp in one go while only reading and parsing it again if it has changed. the +- 300 seconds fuzzing is kept for slightly out-of-sync clusters, since the time encoded in the tickets is the result of time() on whichever node the ticket API call got forwarded to. Signed-off-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
This commit is contained in:
parent
f23ecb7568
commit
21800a71a7
@ -9,6 +9,8 @@ use Net::SSLeay;
|
|||||||
use Net::IP;
|
use Net::IP;
|
||||||
use MIME::Base64;
|
use MIME::Base64;
|
||||||
use Digest::SHA;
|
use Digest::SHA;
|
||||||
|
use IO::File;
|
||||||
|
use File::stat;
|
||||||
|
|
||||||
use PVE::OTP;
|
use PVE::OTP;
|
||||||
use PVE::Ticket;
|
use PVE::Ticket;
|
||||||
@ -33,11 +35,20 @@ PVE::Auth::Plugin->init();
|
|||||||
# $authdir must be writable by root only!
|
# $authdir must be writable by root only!
|
||||||
my $confdir = "/etc/pve";
|
my $confdir = "/etc/pve";
|
||||||
my $authdir = "$confdir/priv";
|
my $authdir = "$confdir/priv";
|
||||||
my $authprivkeyfn = "$authdir/authkey.key";
|
|
||||||
my $authpubkeyfn = "$confdir/authkey.pub";
|
|
||||||
my $pve_www_key_fn = "$confdir/pve-www.key";
|
my $pve_www_key_fn = "$confdir/pve-www.key";
|
||||||
|
|
||||||
|
my $pve_auth_key_files = {
|
||||||
|
priv => "$authdir/authkey.key",
|
||||||
|
pub => "$confdir/authkey.pub",
|
||||||
|
pubold => "$confdir/authkey.pub.old",
|
||||||
|
};
|
||||||
|
|
||||||
|
my $pve_auth_key_cache = {};
|
||||||
|
|
||||||
my $ticket_lifetime = 3600*2; # 2 hours
|
my $ticket_lifetime = 3600*2; # 2 hours
|
||||||
|
# TODO: set to 24h for PVE 6.0
|
||||||
|
my $authkey_lifetime = 3600*0; # rotation disabled
|
||||||
|
|
||||||
Crypt::OpenSSL::RSA->import_random_seed();
|
Crypt::OpenSSL::RSA->import_random_seed();
|
||||||
|
|
||||||
@ -62,16 +73,136 @@ sub lock_user_config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
my $pve_auth_pub_key;
|
my $cache_read_key = sub {
|
||||||
|
my ($type) = @_;
|
||||||
|
|
||||||
|
my $path = $pve_auth_key_files->{$type};
|
||||||
|
|
||||||
|
my $read_key_and_mtime = sub {
|
||||||
|
my $fh = IO::File->new($path, "r");
|
||||||
|
|
||||||
|
return undef if !defined($fh);
|
||||||
|
|
||||||
|
my $st = stat($fh);
|
||||||
|
my $pem = PVE::Tools::safe_read_from($fh, 0, 0, $path);
|
||||||
|
|
||||||
|
close $fh;
|
||||||
|
|
||||||
|
my $key;
|
||||||
|
if ($type eq 'pub' || $type eq 'pubold') {
|
||||||
|
$key = eval { Crypt::OpenSSL::RSA->new_public_key($pem); };
|
||||||
|
} elsif ($type eq 'priv') {
|
||||||
|
$key = eval { Crypt::OpenSSL::RSA->new_private_key($pem); };
|
||||||
|
} else {
|
||||||
|
die "Invalid authkey type '$type'\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key => $key, mtime => $st->mtime };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!defined($pve_auth_key_cache->{$type})) {
|
||||||
|
$pve_auth_key_cache->{$type} = $read_key_and_mtime->();
|
||||||
|
} else {
|
||||||
|
my $st = stat($path);
|
||||||
|
if (!$st || $st->mtime != $pve_auth_key_cache->{$type}->{mtime}) {
|
||||||
|
$pve_auth_key_cache->{$type} = $read_key_and_mtime->();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pve_auth_key_cache->{$type};
|
||||||
|
};
|
||||||
|
|
||||||
sub get_pubkey {
|
sub get_pubkey {
|
||||||
|
my ($old) = @_;
|
||||||
|
|
||||||
return $pve_auth_pub_key if $pve_auth_pub_key;
|
my $type = $old ? 'pubold' : 'pub';
|
||||||
|
|
||||||
my $input = PVE::Tools::file_get_contents($authpubkeyfn);
|
my $res = $cache_read_key->($type);
|
||||||
|
return undef if !defined($res);
|
||||||
|
|
||||||
$pve_auth_pub_key = Crypt::OpenSSL::RSA->new_public_key($input);
|
return wantarray ? ($res->{key}, $res->{mtime}) : $res->{key};
|
||||||
|
}
|
||||||
|
|
||||||
return $pve_auth_pub_key;
|
sub get_privkey {
|
||||||
|
my $res = $cache_read_key->('priv');
|
||||||
|
|
||||||
|
if (!defined($res) || !check_authkey(1)) {
|
||||||
|
rotate_authkey();
|
||||||
|
$res = $cache_read_key->('priv');
|
||||||
|
}
|
||||||
|
|
||||||
|
return wantarray ? ($res->{key}, $res->{mtime}) : $res->{key};
|
||||||
|
}
|
||||||
|
|
||||||
|
sub check_authkey {
|
||||||
|
my ($quiet) = @_;
|
||||||
|
|
||||||
|
# skip check if non-quorate, as rotation is not possible anyway
|
||||||
|
return 1 if !PVE::Cluster::check_cfs_quorum(1);
|
||||||
|
|
||||||
|
my ($pub_key, $mtime) = get_pubkey();
|
||||||
|
if (!$pub_key) {
|
||||||
|
warn "auth key pair missing, generating new one..\n" if !$quiet;
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
if (time() - $mtime >= $authkey_lifetime) {
|
||||||
|
warn "auth key pair too old, rotating..\n" if !$quiet;;
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
warn "auth key new enough, skipping rotation\n" if !$quiet;;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub rotate_authkey {
|
||||||
|
return if $authkey_lifetime == 0;
|
||||||
|
|
||||||
|
cfs_lock_authkey(undef, sub {
|
||||||
|
# re-check with lock to avoid double rotation in clusters
|
||||||
|
return if check_authkey();
|
||||||
|
|
||||||
|
my $old = get_pubkey();
|
||||||
|
|
||||||
|
if ($old) {
|
||||||
|
eval {
|
||||||
|
my $pem = $old->get_public_key_x509_string();
|
||||||
|
PVE::Tools::file_set_contents($pve_auth_key_files->{pubold}, $pem);
|
||||||
|
};
|
||||||
|
die "Failed to store old auth key: $@\n" if $@;
|
||||||
|
}
|
||||||
|
|
||||||
|
my $new = Crypt::OpenSSL::RSA->generate_key(2048);
|
||||||
|
eval {
|
||||||
|
my $pem = $new->get_public_key_x509_string();
|
||||||
|
PVE::Tools::file_set_contents($pve_auth_key_files->{pub}, $pem);
|
||||||
|
};
|
||||||
|
if ($@) {
|
||||||
|
if ($old) {
|
||||||
|
warn "Failed to store new auth key - $@\n";
|
||||||
|
warn "Reverting to previous auth key\n";
|
||||||
|
eval {
|
||||||
|
my $pem = $old->get_public_key_x509_string();
|
||||||
|
PVE::Tools::file_set_contents($pve_auth_key_files->{pub}, $pem);
|
||||||
|
};
|
||||||
|
die "Failed to restore old auth key: $@\n" if $@;
|
||||||
|
} else {
|
||||||
|
die "Failed to store new auth key - $@\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eval {
|
||||||
|
my $pem = $new->get_private_key_string();
|
||||||
|
PVE::Tools::file_set_contents($pve_auth_key_files->{priv}, $pem);
|
||||||
|
};
|
||||||
|
if ($@) {
|
||||||
|
warn "Failed to store new auth key - $@\n";
|
||||||
|
warn "Deleting auth key to force regeneration\n";
|
||||||
|
unlink $pve_auth_key_files->{pub};
|
||||||
|
unlink $pve_auth_key_files->{priv};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
die $@ if $@;
|
||||||
}
|
}
|
||||||
|
|
||||||
my $csrf_prevention_secret;
|
my $csrf_prevention_secret;
|
||||||
@ -100,17 +231,34 @@ sub verify_csrf_prevention_token {
|
|||||||
$secret, $username, $token, -300, $ticket_lifetime, $noerr);
|
$secret, $username, $token, -300, $ticket_lifetime, $noerr);
|
||||||
}
|
}
|
||||||
|
|
||||||
my $pve_auth_priv_key;
|
my $get_ticket_age_range = sub {
|
||||||
sub get_privkey {
|
my ($now, $mtime, $rotated) = @_;
|
||||||
|
|
||||||
return $pve_auth_priv_key if $pve_auth_priv_key;
|
my $key_age = $now - $mtime;
|
||||||
|
$key_age = 0 if $key_age < 0;
|
||||||
|
|
||||||
my $input = PVE::Tools::file_get_contents($authprivkeyfn);
|
my $min = -300;
|
||||||
|
my $max = $ticket_lifetime;
|
||||||
|
|
||||||
$pve_auth_priv_key = Crypt::OpenSSL::RSA->new_private_key($input);
|
if ($rotated) {
|
||||||
|
# ticket creation after rotation is not allowed
|
||||||
|
$min = $key_age - 300;
|
||||||
|
} else {
|
||||||
|
if ($key_age > $authkey_lifetime && $authkey_lifetime > 0) {
|
||||||
|
if (PVE::Cluster::check_cfs_quorum(1)) {
|
||||||
|
# key should have been rotated, clamp range accordingly
|
||||||
|
$min = $key_age - $authkey_lifetime;
|
||||||
|
} else {
|
||||||
|
warn "Cluster not quorate - extending auth key lifetime!\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $pve_auth_priv_key;
|
$max = $key_age + 300 if $key_age < $ticket_lifetime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undef if $min > $ticket_lifetime;
|
||||||
|
return ($min, $max);
|
||||||
|
};
|
||||||
|
|
||||||
sub assemble_ticket {
|
sub assemble_ticket {
|
||||||
my ($username) = @_;
|
my ($username) = @_;
|
||||||
@ -123,12 +271,34 @@ sub assemble_ticket {
|
|||||||
sub verify_ticket {
|
sub verify_ticket {
|
||||||
my ($ticket, $noerr) = @_;
|
my ($ticket, $noerr) = @_;
|
||||||
|
|
||||||
my $rsa_pub = get_pubkey();
|
my $now = time();
|
||||||
|
|
||||||
my ($username, $age) = PVE::Ticket::verify_rsa_ticket(
|
my $check = sub {
|
||||||
$rsa_pub, 'PVE', $ticket, undef, -300, $ticket_lifetime, $noerr);
|
my ($old) = @_;
|
||||||
|
|
||||||
return undef if $noerr && !defined($username);
|
my ($rsa_pub, $rsa_mtime) = get_pubkey($old);
|
||||||
|
return undef if !$rsa_pub;
|
||||||
|
|
||||||
|
my ($min, $max) = $get_ticket_age_range->($now, $rsa_mtime, $old);
|
||||||
|
return undef if !$min;
|
||||||
|
|
||||||
|
return PVE::Ticket::verify_rsa_ticket(
|
||||||
|
$rsa_pub, 'PVE', $ticket, undef, $min, $max, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
my ($username, $age) = $check->();
|
||||||
|
|
||||||
|
# check with old, rotated key if current key failed
|
||||||
|
($username, $age) = $check->(1) if !defined($username);
|
||||||
|
|
||||||
|
if (!defined($username)) {
|
||||||
|
if ($noerr) {
|
||||||
|
return undef;
|
||||||
|
} else {
|
||||||
|
# raise error via undef ticket
|
||||||
|
PVE::Ticket::verify_rsa_ticket(undef, 'PVE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return undef if !PVE::Auth::Plugin::verify_username($username, $noerr);
|
return undef if !PVE::Auth::Plugin::verify_username($username, $noerr);
|
||||||
|
|
||||||
@ -154,10 +324,18 @@ sub assemble_vnc_ticket {
|
|||||||
sub verify_vnc_ticket {
|
sub verify_vnc_ticket {
|
||||||
my ($ticket, $username, $path, $noerr) = @_;
|
my ($ticket, $username, $path, $noerr) = @_;
|
||||||
|
|
||||||
my $rsa_pub = get_pubkey();
|
|
||||||
|
|
||||||
my $secret_data = "$username:$path";
|
my $secret_data = "$username:$path";
|
||||||
|
|
||||||
|
my ($rsa_pub, $rsa_mtime) = get_pubkey();
|
||||||
|
if (!$rsa_pub || (time() - $rsa_mtime > $authkey_lifetime)) {
|
||||||
|
if ($noerr) {
|
||||||
|
return undef;
|
||||||
|
} else {
|
||||||
|
# raise error via undef ticket
|
||||||
|
PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVEVNC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return PVE::Ticket::verify_rsa_ticket(
|
return PVE::Ticket::verify_rsa_ticket(
|
||||||
$rsa_pub, 'PVEVNC', $ticket, $secret_data, -20, 40, $noerr);
|
$rsa_pub, 'PVEVNC', $ticket, $secret_data, -20, 40, $noerr);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user