public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Shannon Sterz" <s.sterz@proxmox.com>
To: "Mark Atwood" <mark@wolfssl.com>, <pve-devel@lists.proxmox.com>
Subject: Re: [PATCH common 1/2] ticket: accept RSA tickets signed with SHA-256
Date: Mon, 02 Nov 2026 15:07:29 +0100	[thread overview]
Message-ID: <DMKS7YDS2YS7.11BF4DKI67SG1@proxmox.com> (raw)
In-Reply-To: <20261023185749.509347-1-mark@wolfssl.com>

On Fri Oct 23, 2026 at 8:57 PM CEST, Mark Atwood wrote:
> Crypt::OpenSSL::RSA signs and verifies with SHA-1 unless told otherwise,
> so all RSA tickets (login, TFA, VNC, tunnel) are RSA PKCS#1 v1.5
> signatures over SHA-1. NIST SP 800-131A Rev. 2 no longer allows SHA-1 for
> creating digital signatures. FIPS providers for OpenSSL 3 can enforce
> this, for example wolfProvider, or the OpenSSL FIPS provider with its
> signature digest check enabled. With such a provider no ticket can be
> issued and nobody can log in.
>
> As a first step, make verification accept tickets signed with SHA-256 in
> addition to SHA-1. Once all nodes of a cluster run a version with this
> change, the signing side can switch to SHA-256 without locking users out
> of nodes that were not yet upgraded.
>
> The hash is set explicitly before each attempt, since callers cache and
> reuse the key object. A provider that does not allow SHA-1 raises an
> error from verify() instead of returning false, so the call is wrapped
> in eval and such a ticket is treated like any other invalid ticket.
>
> Add a test that signs tickets directly with Crypt::OpenSSL::RSA using
> either hash, and checks that they are accepted and that modified,
> expired, foreign-key or wrong-secret tickets are rejected.
>
> Signed-off-by: Mark Atwood <mark@wolfssl.com>
> ---
>  src/PVE/Ticket.pm   | 15 +++++++++-
>  test/Makefile       |  1 +
>  test/ticket_test.pl | 68 +++++++++++++++++++++++++++++++++++++++++++++
>  3 files changed, 83 insertions(+), 1 deletion(-)
>  create mode 100755 test/ticket_test.pl
>
> diff --git a/src/PVE/Ticket.pm b/src/PVE/Ticket.pm
> index 764c02d..f700f39 100644
> --- a/src/PVE/Ticket.pm
> +++ b/src/PVE/Ticket.pm
> @@ -79,8 +79,21 @@ sub verify_rsa_ticket {
>          my $sig = $2;
>
>          my $full = defined($secret_data) ? "$plain:$secret_data" : $plain;
> +        my $raw_sig = decode_base64($sig);
> +
> +        # Older versions sign with SHA-1, so during a rolling upgrade a cluster has tickets
> +        # with either hash. A provider that refuses SHA-1 raises an error rather than
> +        # returning false, which simply makes such a ticket invalid.

nit: This could probably benefit from comment like:

    # TODO: Remove SHA-1 fallback once SHA-256 signed tickets are the
    # default across PMG and PVE for at least one major release.

> +        my $valid;
> +        for my $use_hash ('use_sha256_hash', 'use_sha1_hash') {
> +            $valid = eval {
> +                $rsa_pub->$use_hash();
> +                $rsa_pub->verify($full, $raw_sig);

It might be worth to add documentation that the state of the RSA pub key
object may be modified depending on what ticket is being verified. If a
SHA-256 ticket is verified, other users of `$rsa_pub` will now use
SHA-256 for verifying signatures in other contexts; if the ticket is
SHA-1, then SHA-1 will be used.

This can make it hard to tell from a caller's perspective in what state
the object will be after a call to this function. IMO, this should at
least be documented or maybe we should reset the object after the last
`verify()` call unconditionally. What do you think?

Note: From a quick check, none of the users should have a reason to call
`verify()` outside of the functions in `PVE::Ticket`. Here you always
correctly set the hash function before using the object, so it shouldn't
be an issue in practice.

> +            };

The eval call also swallows *all* errors that could happen during
verification, might be worth checking what `$@` contains and warning
about it.

> +            last if $valid;
> +        }
>
> -        if ($rsa_pub->verify($full, decode_base64($sig))) {
> +        if ($valid) {
>              if ($plain =~ m/^\Q$prefix\E:(?:(\S+):)?([A-Z0-9]{8})$/) {
>                  my $data = $1; # Note: not all tickets contains data
>                  my $timestamp = $2;
> diff --git a/test/Makefile b/test/Makefile
> index 1bce185..7eac9ea 100644
> --- a/test/Makefile
> +++ b/test/Makefile
> @@ -16,6 +16,7 @@ TESTS = lock_file.test			\
>  	section_config_property_isolation_test.test \
>  	file-test.test \
>  	cmd-test.test \
> +	ticket_test.test \
>
>  all:
>
> diff --git a/test/ticket_test.pl b/test/ticket_test.pl
> new file mode 100755
> index 0000000..37e30b4
> --- /dev/null
> +++ b/test/ticket_test.pl
> @@ -0,0 +1,68 @@
> +#!/usr/bin/perl
> +
> +# Tests for signing and verifying RSA tickets with PVE::Ticket
> +
> +use v5.36;
> +
> +use lib '../src';
> +
> +use Crypt::OpenSSL::RSA;
> +use MIME::Base64;
> +use Test::More;
> +
> +use PVE::Ticket;
> +
> +my $rsa_priv = Crypt::OpenSSL::RSA->generate_key(2048);
> +my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($rsa_priv->get_public_key_string());
> +
> +# sign like a node would, with an explicitly chosen hash, independent of PVE::Ticket
> +sub sign_ticket($priv, $hash, $plain, $secret_data = undef) {
> +    my $full = defined($secret_data) ? "$plain:$secret_data" : $plain;
> +    my $use_hash = "use_${hash}_hash";
> +    $priv->$use_hash();
> +    return $plain . "::" . encode_base64($priv->sign($full), '');
> +}
> +
> +my $now = sprintf("%08X", time());
> +my $old = sprintf("%08X", time() - 7200);
> +
> +for my $hash (qw(sha256 sha1)) {
> +    my $ticket = sign_ticket($rsa_priv, $hash, "PVE:root\@pam:$now");
> +    my ($data, $age) = PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVE', $ticket, undef, -1, 60, 1);
> +    is($data, 'root@pam', "$hash signed ticket is accepted");
> +    ok(defined($age) && $age >= 0 && $age < 60, "$hash signed ticket has plausible age");
> +
> +    $ticket = sign_ticket($rsa_priv, $hash, "PVEVNC:$now", 'vnc-secret');
> +    ok(
> +        PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVEVNC', $ticket, 'vnc-secret', -20, 40, 1),
> +        "$hash signed ticket with secret data is accepted",
> +    );
> +    ok(
> +        !PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVEVNC', $ticket, 'other', -20, 40, 1),
> +        "$hash signed ticket with wrong secret data is rejected",
> +    );
> +
> +    $ticket = sign_ticket($rsa_priv, $hash, "PVE:root\@pam:$now");
> +    (my $tampered = $ticket) =~ s/^PVE:root\@pam:/PVE:admin\@pam:/;
> +    ok(
> +        !PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVE', $tampered, undef, -1, 60, 1),
> +        "$hash signed ticket with modified data is rejected",
> +    );
> +    eval { PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVE', $tampered, undef, -1, 60) };
> +    like($@, qr/invalid PVE ticket/, "$hash signed modified ticket raises error without noerr");

nit: Would benefit from a test case that checks whether verification
succeeds without `$noerr` set too.

> +
> +    $ticket = sign_ticket($rsa_priv, $hash, "PVE:root\@pam:$old");
> +    ok(
> +        !PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVE', $ticket, undef, -1, 60, 1),
> +        "$hash signed expired ticket is rejected",
> +    );
> +
> +    my $other_priv = Crypt::OpenSSL::RSA->generate_key(2048);
> +    $ticket = sign_ticket($other_priv, $hash, "PVE:root\@pam:$now");
> +    ok(
> +        !PVE::Ticket::verify_rsa_ticket($rsa_pub, 'PVE', $ticket, undef, -1, 60, 1),
> +        "$hash signed ticket from another key is rejected",
> +    );
> +}
> +
> +done_testing();





  reply	other threads:[~2026-11-02 14:07 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-23 18:36 [PATCH common 0/2] ticket: sign RSA tickets with SHA-256 Mark Atwood
2026-10-23 18:57 ` [PATCH common 1/2] ticket: accept RSA tickets signed " Mark Atwood
2026-11-02 14:07   ` Shannon Sterz [this message]
2026-10-23 18:57 ` [PATCH common 2/2] ticket: sign RSA tickets " Mark Atwood
2026-11-02 14:07   ` Shannon Sterz
2026-11-02 14:07 ` [PATCH common 0/2] " Shannon Sterz

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=DMKS7YDS2YS7.11BF4DKI67SG1@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=mark@wolfssl.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal