#!/usr/bin/perl
#
# Copyright (c) 2026 SUSE LLC
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program (see the file COPYING); if not, write to the
# Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
#
################################################################

use strict;

use File::Temp ();

my $keyring_directory = '/usr/lib/rpm/pqkeys';
my $allow_non_pqc;

my $exit_main_loop;

#
# IO helpers
#
sub slurp {
  my ($fn) = @_;
  my $fd;
  open($fd, '<', $fn) || die("$fn: $!\n");
  local $/;
  my $data = <$fd>;
  die("$fn: $!\n") unless defined $data;
  close $fd;
  return $data;
}

sub ls {
  my $d;
  opendir($d, $_[0]) || return ();
  my @r = grep {$_ ne '.' && $_ ne '..'} readdir($d);
  closedir $d;
  return @r;
}

#
# CMS parameter verification
#
my $CONS       = 0x20;
my $CONT       = 0x80;

my $INTEGER    = 0x02;
my $OCTET_STRING = 0x04;
my $OBJ_ID     = 0x06;
my $SEQUENCE   = 0x10;
my $SET        = 0x11;

my $cms_tags = [ $OBJ_ID, [ $CONS | $CONT | 0, undef ] ];
my $cms_tags_sd = [ $INTEGER, $CONS | $SET, $CONS | $SEQUENCE, [ $CONS | $CONT | 0, undef ], [ $CONS | $CONT | 1, undef ], $CONS | $SET ];
my $cms_tags_ci = [ $OBJ_ID, [ $CONS | $CONT | 0, undef ] ];
my $cms_tags_si = [ $INTEGER, [ $CONS | $SEQUENCE, $CONT | 0 ], $CONS | $SEQUENCE, [ $CONS | $CONT | 0, undef ], $CONS | $SEQUENCE, $OCTET_STRING , [ $CONS | $CONT | 1, undef ] ];
my $oid_pkcs7_signed_data = pack_obj_id(1, 2, 840, 113549, 1, 7, 2);

my $oid_mldsa65 = pack_obj_id(2, 16, 840, 1, 101, 3, 4, 3, 18);
my $oid_mldsa87 = pack_obj_id(2, 16, 840, 1, 101, 3, 4, 3, 19);
my $oid_sha512 = pack_obj_id(2, 16, 840, 1, 101, 3, 4, 2, 3);
my $oid_sha3_512 = pack_obj_id(2, 16, 840, 1, 101, 3, 4, 2, 10);

sub pack_obj_id {
  my ($o1, $o2, @o) = @_;
  my $data = pack('w*', $o1 * 40 + $o2, @o);
  die("not implemented\n") if length($data) >= 128;
  return pack("CC", $OBJ_ID, length($data)).$data;
}

sub unpack_raw {
  my ($in, $allowed, $optional, $exact) = @_;
  $allowed = [ $allowed ] if $allowed && !ref($allowed);
  if (length($in) < 2) {
    return ($in, undef, undef, '') if $optional || grep {!defined($_)} @{$allowed || []};
    die("unexpected end of asn1 data\n");
  }
  my ($tag, $l) = unpack("CC", $in);
  if ($allowed) {
    if (!grep {defined($_) && ($tag == $_ || !$_)} @$allowed) {
      return ($in, undef, undef, '') if $optional || grep {!defined($_)} @{$allowed || []};
      die("unexpected tag $tag, expected @$allowed\n");
    }
  }
  my $off = 2;
  if ($l >= 128) {
    $l -= 128;
    $off += $l;
    die("unsupported asn1 length $l\n") if $l < 1 || $l > 4;
    $l = unpack("\@${l}N", pack('xxxx').substr($in, 2, 4));
  }
  die("unexpected end of asn1 data\n") if length($in) < $off + $l;
  die("tailing data at end of asn1 element\n") if $exact && length($in) != $off + $l;
  return (substr($in, $off + $l), $tag, substr($in, $off, $l), substr($in, 0, $off + $l));
}

sub gettag {
  return (unpack_raw(@_))[1];
}

sub unpack_body {
  my ($in, $tag, $default) = @_;
  return (unpack_raw($in, defined($tag) ? $tag : $default, undef, 1))[2];
}

sub unpack_tagged {
  return unpack_body($_[0], $_[1], 0);
}

sub unpack_sequence {
  my ($in, $tag, $allowed) = @_;
  $in = unpack_body($in, $tag, $CONS | $SEQUENCE);
  my @ret;
  my $tagbody;
  if ($allowed && ref($allowed)) {
    for my $all (@$allowed) {
      return @ret, $in if $all && !ref($all) && $all == -1;     # -1: get rest
      ($in, undef, undef, $tagbody) = unpack_raw($in, $all);
      push @ret, $tagbody;
    }
    die("tailing data at end of asn1 sequence\n") if $in ne '';
    return @ret;
  }
  while ($in ne '') {
    ($in, undef, undef, $tagbody) = unpack_raw($in, $allowed);
    push @ret, $tagbody;
  }
  return @ret;
}

sub unpack_set {
  my ($in, $tag, $allowed) = @_;
  $in = unpack_body($in, $tag, $CONS | $SET);
  my @ret;
  my $tagbody;
  while ($in ne '') {
    ($in, undef, undef, $tagbody) = unpack_raw($in, $allowed);
    push @ret, $tagbody;
  }
  return @ret;
}

sub is_allowed_algo {
  my ($oid) = unpack_sequence($_[0], $_[1], [ $OBJ_ID, [0, undef] ]);
  return 1 if $allow_non_pqc;
  return 1 if $oid eq $oid_mldsa65;
  return 1 if $oid eq $oid_mldsa87;
  return 0;
}

# see rfc9882 table 1
sub is_allowed_digest {
  my ($oid) = unpack_sequence($_[0], $_[1], [ $OBJ_ID, [0, undef] ]);
  return 1 if $oid eq $oid_sha512;
  return 1 if $oid eq $oid_sha3_512;
  return 0;
}

sub verify_cms_parameters {
  my ($cmsfile) = @_;
  my $p7s = slurp($cmsfile);
  my @cm = unpack_sequence($p7s, undef, $cms_tags);
  die("cms is not of type signed data\n") unless $cm[0] eq $oid_pkcs7_signed_data;
  my @sd = unpack_sequence(unpack_tagged($cm[1], $CONS | $CONT | 0), undef, $cms_tags_sd);
  my @ci = unpack_sequence($sd[2], undef, $cms_tags_ci);
  die("cms does not have a detached content\n") if $ci[1];
  my @sis = unpack_set($sd[5]);
  die("cms does not have a signer info\n") unless @sis;
  for my $si (@sis) {
    my @si = unpack_sequence($sis[0], undef, $cms_tags_si);
    die("not a pqc signature\n") unless is_allowed_algo($si[4]);
    die("not a strong digest\n") unless is_allowed_digest($si[2]);
  }
}

#
# Signature verificaton
#
sub generate_keyfile {
  my $keyfile = File::Temp->new();
  for my $fn (sort(ls($keyring_directory ))) {
    next if $fn =~ /^\./ || $fn !~ /\.(?:pem|crt)$/;
    next if -s "$keyring_directory/$fn" >= 1024*1024;
    my $crt = slurp("$keyring_directory/$fn");
    print $keyfile $crt;
  }
  $keyfile->flush();
  return $keyfile;
}

sub get_openssl_version {
  my $fh;
  open($fh, '-|', 'openssl', 'version') || die("openssl: $!\n");
  local $/;
  my $out = <$fh>;
  close $fh;
  return $1 * 100 + $2 if $out =~ /^openssl (\d+)\.(\d+)/i;
  die("could not determine openssl version\n");
}

sub verify_cms_with_openssl {
  my ($keyfile, $contentfile, $cmsfile) = @_;
  my $openssl_version = get_openssl_version();
  my @provider;
  @provider = ('-provider', 'default', '-provider', 'oqsprovider' ) if $openssl_version < 305;
  my $fh;
  my $pid = open($fh, '-|');
  die("fork: $!\n") unless defined $pid;
  if (!$pid) {
    open(STDERR, '>&STDOUT');
    open(STDOUT, '>/dev/null');
    exec('openssl', 'cms', @provider, '-verify', '-binary', '-in', $cmsfile, '-inform', 'DER', '-content', $contentfile, '-certfile', $keyfile, '-CAfile', $keyfile, '-no-CApath');
    die("openssl: $!\n");
  }
  local $/;
  my $out = <$fh>;
  if (!close($fh)) {
    print STDERR $out if $out;
    die("signature verification failed\n");
  }
}

#
# STOMP protocol implementation
#
my %unquote = ( 'r' => "\r", 'n' => "\n", 'c' => ":", '\\' => '\\' );
my %quote = reverse(%unquote);

sub readframe {
  my ($fd) = @_;
  my $cmd;
  while (1) {
    $cmd = <$fd>;
    return unless defined $cmd;
    chomp $cmd;
    last if $cmd ne '';
  }
  my @headers;
  while (1) {
    my $line = <$fd>;
    die("readframe: unexpected EOF\n") unless defined $line;
    chomp $line;
    last if $line eq '';
    my @h = split(':', $line, 2);
    die("readframe: bad header line\n") unless @h == 2;
    push @headers, @h;
  }
  $cmd =~ s/\\([rnc\\])/$unquote{$1}/sge;
  s/\\([rnc\\])/$unquote{$1}/sge for @headers;
  my $cl = { @headers }->{'content-length'};
  my $body = '';
  if (defined($cl)) {
    die("bad content length\n") unless $cl =~ /^\d+$/;
    while (length($body) < 1 + $cl) {
      my $r = read($fd, $body, 1 + $cl - length($body), length($body));
      die("readframe: unexpected EOF\n") unless $r;
    }
  } else {
    local $/ = "\0";
    $body = <$fd>;
  }
  die("readframe: missing NUL at end of body\n") unless chop($body) eq "\0";
  return ($cmd, \@headers, $body);
}

sub writeframe {
  my ($fd, $cmd, $headers, $body) = @_;
  $body = '' unless defined $body;
  my @headers  = @{$headers || []};
  s/([\r\n:\\])/"\\".$quote{$1}/sge for @headers;
  $cmd =~ s/([\r\n:\\])/"\\".$quote{$1}/sge;
  my @lines = ($cmd);
  push @lines, "content-length:".length($body);
  while (@headers) {
    my ($k, $v) = splice(@headers, 0, 2);
    $v = '' unless defined $v;
    push @lines, "$k:$v" if defined($k) && $k ne 'content-length';
  }
  print $fd "$_\n" for @lines;
  print $fd "\n$body\0";
  $fd->flush();
}

#
# Commands
#
sub verify_repo_signature {
  my ($contentfile, $sig) = @_;

  die("$sig: $!\n") unless -e $sig;
  die("$sig: insane size\n") if -s $sig > 1024*1024;
  die("$contentfile: $!\n") unless -e $contentfile;
  verify_cms_parameters($sig);
  my $keyfile = generate_keyfile();
  verify_cms_with_openssl($keyfile, $contentfile, $sig);
}

sub cmd_unsupported {
  my ($cmd, $headers, $body) = @_;
  die("Unsupported command $cmd\n");
}

sub cmd_disconnect {
  $exit_main_loop = 1;
  return ('ACK');
}

sub cmd_pluginbegin {
  my ($cmd, $headers, $body) = @_;
  my %h = @$headers;
  die("missing version header in PLUGINBEGIN\n") unless defined $h{'version'};
  my @h = ('sig_extension' => '.p7s');
  return ('PLUGINSETUP', \@h);
}

sub cmd_sigcheck {
  my ($cmd, $headers, $body) = @_;
  my %h = @$headers;
  die("Missing sig header in SIGCHECK\n") unless defined $h{'sig'};
  die("Missing data header in SIGCHECK\n") unless defined $h{'data'};
  verify_repo_signature($h{'data'}, $h{'sig'});
  return ('ACK');
}

my %commands = (
  '_DISCONNECT' => \&cmd_disconnect,
  'PLUGINBEGIN' => \&cmd_pluginbegin,
  'SIGCHECK' => \&cmd_sigcheck,
);

#
# Main
#
if (@ARGV && $ARGV[0] eq '--allow-non-pqc') {
  $allow_non_pqc = 1;
  shift @ARGV;
}

if (@ARGV && $ARGV[0] eq '--verify-repo-signature') {
  verify_repo_signature($ARGV[1], $ARGV[2]);
  exit(0);
}

my $ifd = \*STDIN;
my $ofd;
open($ofd, ">&STDOUT") || die("STDOUT dup\n");
open(STDOUT, ">&STDERR") || die("STDERR dup\n");

while (!$exit_main_loop) {
  my ($cmd, $headers, $body) = readframe($ifd);
  last unless defined $cmd;
  my @response = eval {($commands{$cmd} || \&cmd_unsupported)->($cmd, $headers, $body) };
  @response = ('ERROR', undef, $@) if $@;
  writeframe($ofd, @response);
}

