[pve-devel] [RFC access-control 1/2] fix #2079: add periodic auth key rotation

Thomas Lamprecht t.lamprecht at proxmox.com
Wed Mar 6 10:23:05 CET 2019


On 3/6/19 8:21 AM, Fabian Grünbichler wrote:
> 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 at proxmox.com>
> ---
> 
> Notes:
>     VERSIONED DEPENDENCY: on pve-cluster with cfs_lock_authkey needed
>     
>     it would also be possible to extend pve-common's Ticket.pm to support a list of
>     keys + age ranges, but that makes the interface rather non-straight-forward..
>     
>     if there are no objections to this approach in general, extending it for VNC
>     tickets should be straight-forward - although they are only valid for a minute
>     anyway ;)
>     
>     a similar approach could also work for CSRF tokens if desired..
>     
>     the auth key lifetime can of course also be set to a bigger value (e.g., 24h)

just for the record from off-line discussion: looks OK in general, but I'd like that
a user still can login if the node/cluster wasn't quorate for a time span where a
rotation would've needed to happen (plus grace period). One shouldn't be able to
modify things (i.e., do harm) in a non-quorate situation anyway and for checking the
state of a node it's convenient, else a user could be confused/annoyed if login
just fails.


> 
>  PVE/AccessControl.pm | 211 +++++++++++++++++++++++++++++++++++++++----
>  1 file changed, 191 insertions(+), 20 deletions(-)
> 
> diff --git a/PVE/AccessControl.pm b/PVE/AccessControl.pm
> index bdadfd2..ff6413c 100644
> --- a/PVE/AccessControl.pm
> +++ b/PVE/AccessControl.pm
> @@ -9,6 +9,8 @@ use Net::SSLeay;
>  use Net::IP;
>  use MIME::Base64;
>  use Digest::SHA;
> +use IO::File;
> +use File::stat;
>  
>  use PVE::OTP;
>  use PVE::Ticket;
> @@ -33,11 +35,19 @@ PVE::Auth::Plugin->init();
>  # $authdir must be writable by root only!
>  my $confdir = "/etc/pve";
>  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_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 $authkey_lifetime = 3600*3; # 3 hours
>  
>  Crypt::OpenSSL::RSA->import_random_seed();
>  
> @@ -62,16 +72,131 @@ 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 {
> +    my ($old) = @_;
> +
> +    my $type = $old ? 'pubold' : 'pub';
> +
> +    my $res = $cache_read_key->($type);
> +    return undef if !defined($res);
> +
> +    return wantarray ? ($res->{key}, $res->{mtime}) : $res->{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) = @_;
> +
> +    my ($pub_key, $mtime) = get_pubkey();
> +    if (!$pub_key) {
> +	warn('auth key pair missing, generating new one..') if !$quiet;
> +	return 0;
> +    } else {
> +	if (time() - $mtime >= $authkey_lifetime) {
> +	    warn('auth key pair too old, rotating..') if !$quiet;;
> +	    return 0;
> +	} else {
> +	    warn('auth key new enough, skipping rotation') if !$quiet;;
> +	    return 1;
> +	}
> +    }
> +}
>  
> -    return $pve_auth_pub_key if $pve_auth_pub_key;
> +sub rotate_authkey {
> +    my $res = PVE::Cluster::cfs_lock_authkey(undef, sub {
> +	# re-check with lock to avoid double rotation in clusters
> +	return if check_authkey();
>  
> -    my $input = PVE::Tools::file_get_contents($authpubkeyfn);
> +	my $old = get_pubkey();
>  
> -    $pve_auth_pub_key = Crypt::OpenSSL::RSA->new_public_key($input);
> +	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";
> +	    }
> +	}
>  
> -    return $pve_auth_pub_key;
> +	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 (!defined($res) && $@);
>  }
>  
>  my $csrf_prevention_secret;
> @@ -100,17 +225,33 @@ sub verify_csrf_prevention_token {
>  	$secret, $username, $token, -300, $ticket_lifetime, $noerr);
>  }
>  
> -my $pve_auth_priv_key;
> -sub get_privkey {
> +my $get_ticket_age_range = sub {
> +    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, $max);
>  
> -    $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) {
> +	    # key should have been rotated, clamp range accordingly
> +	    $min = $key_age - $authkey_lifetime;
> +	} else {
> +	    $min = -300;
> +	}
> +    }
>  
> -    return $pve_auth_priv_key;
> -}
> +    return undef if $min > $ticket_lifetime;
> +
> +    $max = $ticket_lifetime;
> +    $max = $key_age + 300 if (!$rotated && $key_age < $ticket_lifetime);
> +
> +    return ($min, $max);
> +};
>  
>  sub assemble_ticket {
>      my ($username) = @_;
> @@ -123,12 +264,34 @@ sub assemble_ticket {
>  sub verify_ticket {
>      my ($ticket, $noerr) = @_;
>  
> -    my $rsa_pub = get_pubkey();
> +    my $now = time();
>  
> -    my ($username, $age) = PVE::Ticket::verify_rsa_ticket(
> -	$rsa_pub, 'PVE', $ticket, undef, -300, $ticket_lifetime, $noerr);
> +    my $check = sub {
> +	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);
>  
> @@ -154,10 +317,18 @@ sub assemble_vnc_ticket {
>  sub verify_vnc_ticket {
>      my ($ticket, $username, $path, $noerr) = @_;
>  
> -    my $rsa_pub = get_pubkey();
> -
>      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(
>  	$rsa_pub, 'PVEVNC', $ticket, $secret_data, -20, 40, $noerr);
>  }
> 






More information about the pve-devel mailing list