+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) = @_;
+
+ # 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 {
+ my $now = time();
+ if ($now - $mtime >= $authkey_lifetime) {
+ warn "auth key pair too old, rotating..\n" if !$quiet;;
+ return 0;
+ } elsif ($mtime > $now + $auth_graceperiod) {
+ # a nodes RTC had a time set in the future during key generation -> ticket
+ # validity is clamped to 0+5 min grace period until now >= mtime again
+ my (undef, $old_mtime) = get_pubkey(1);
+ if ($old_mtime && $mtime >= $old_mtime && $mtime - $old_mtime < $ticket_lifetime) {
+ warn "auth key pair generated in the future (key $mtime > host $now),"
+ ." but old key still exists and in valid grace period so avoid automatic"
+ ." fixup. Cluster time not in sync?\n" if !$quiet;
+ return 1;
+ }
+ warn "auth key pair generated in the future (key $mtime > host $now), 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;
+
+ PVE::Cluster::cfs_lock_authkey(undef, sub {
+ # re-check with lock to avoid double rotation in clusters
+ return if check_authkey();
+
+ my $old = get_pubkey();
+ my $new = Crypt::OpenSSL::RSA->generate_key(2048);
+
+ if ($old) {
+ eval {
+ my $pem = $old->get_public_key_x509_string();
+ # mtime is used for caching and ticket age range calculation
+ PVE::Tools::file_set_contents($pve_auth_key_files->{pubold}, $pem);
+ };
+ die "Failed to store old auth key: $@\n" if $@;
+ }
+
+ eval {
+ my $pem = $new->get_public_key_x509_string();
+ # mtime is used for caching and ticket age range calculation,
+ # should be close to that of pubold above
+ 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 $@;
+}
+
+PVE::JSONSchema::register_standard_option('tokenid', {
+ description => "API token identifier.",
+ type => "string",
+ format => "pve-tokenid",
+});
+
+our $token_subid_regex = $PVE::Auth::Plugin::realm_regex;
+
+# username@realm username realm tokenid
+our $token_full_regex = qr/((${PVE::Auth::Plugin::user_regex})\@(${PVE::Auth::Plugin::realm_regex}))!(${token_subid_regex})/;
+
+our $userid_or_token_regex = qr/^$PVE::Auth::Plugin::user_regex\@$PVE::Auth::Plugin::realm_regex(?:!$token_subid_regex)?$/;
+
+sub split_tokenid {
+ my ($tokenid, $noerr) = @_;
+
+ if ($tokenid =~ /^${token_full_regex}$/) {
+ return ($1, $4);
+ }
+
+ die "'$tokenid' is not a valid token ID - not able to split into user and token parts\n" if !$noerr;
+
+ return undef;
+}
+
+sub join_tokenid {
+ my ($username, $tokensubid) = @_;
+
+ my $joined = "${username}!${tokensubid}";