summaryrefslogtreecommitdiffstats
path: root/scripts/Dpkg/OpenPGP
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 14:58:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 14:58:51 +0000
commitcbffab246997fb5a06211dfb706b54e5ae5bb59f (patch)
tree0573c5d96f58d74d76a49c0f2a70398e389a36d3 /scripts/Dpkg/OpenPGP
parentInitial commit. (diff)
downloaddpkg-cbffab246997fb5a06211dfb706b54e5ae5bb59f.tar.xz
dpkg-cbffab246997fb5a06211dfb706b54e5ae5bb59f.zip
Adding upstream version 1.21.22.upstream/1.21.22upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'scripts/Dpkg/OpenPGP')
-rw-r--r--scripts/Dpkg/OpenPGP/Backend.pm127
-rw-r--r--scripts/Dpkg/OpenPGP/Backend/GnuPG.pm301
-rw-r--r--scripts/Dpkg/OpenPGP/Backend/SOP.pm108
-rw-r--r--scripts/Dpkg/OpenPGP/Backend/Sequoia.pm123
-rw-r--r--scripts/Dpkg/OpenPGP/ErrorCodes.pm93
-rw-r--r--scripts/Dpkg/OpenPGP/KeyHandle.pm103
6 files changed, 855 insertions, 0 deletions
diff --git a/scripts/Dpkg/OpenPGP/Backend.pm b/scripts/Dpkg/OpenPGP/Backend.pm
new file mode 100644
index 0000000..48cfd22
--- /dev/null
+++ b/scripts/Dpkg/OpenPGP/Backend.pm
@@ -0,0 +1,127 @@
+# Copyright © 2017, 2022 Guillem Jover <guillem@debian.org>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# 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. If not, see <https://www.gnu.org/licenses/>.
+
+package Dpkg::OpenPGP::Backend;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.01';
+
+use List::Util qw(first);
+
+use Dpkg::Path qw(find_command);
+use Dpkg::OpenPGP::ErrorCodes;
+
+sub DEFAULT_CMDV {
+ return [];
+}
+
+sub DEFAULT_CMDSTORE {
+ return [];
+}
+
+sub DEFAULT_CMD {
+ return [];
+}
+
+sub _detect_cmd {
+ my ($cmd, $default) = @_;
+
+ if (! defined $cmd || $cmd eq 'auto') {
+ return first { find_command($_) } @{$default};
+ } else {
+ return find_command($cmd);
+ }
+}
+
+sub new {
+ my ($this, %opts) = @_;
+ my $class = ref($this) || $this;
+
+ my $self = {
+ strict_verify => $opts{strict_verify} // 1,
+ };
+ bless $self, $class;
+
+ $self->{cmdv} = _detect_cmd($opts{cmdv}, $self->DEFAULT_CMDV());
+ $self->{cmdstore} = _detect_cmd($opts{cmdstore}, $self->DEFAULT_CMDSTORE());
+ $self->{cmd} = _detect_cmd($opts{cmd}, $self->DEFAULT_CMD());
+
+ return $self;
+}
+
+sub has_backend_cmd {
+ my $self = shift;
+
+ return defined $self->{cmd};
+}
+
+sub has_verify_cmd {
+ my $self = shift;
+
+ return defined $self->{cmd};
+}
+
+sub has_keystore {
+ my $self = shift;
+
+ return 0;
+}
+
+sub can_use_key {
+ my ($self, $key) = @_;
+
+ return $self->has_keystore() if $key->needs_keystore();
+ return 1;
+}
+
+sub get_trusted_keyrings {
+ my $self = shift;
+
+ return ();
+}
+
+sub armor {
+ my ($self, $type, $in, $out) = @_;
+
+ return OPENPGP_UNSUPPORTED_SUBCMD;
+}
+
+sub dearmor {
+ my ($self, $type, $in, $out) = @_;
+
+ return OPENPGP_UNSUPPORTED_SUBCMD;
+}
+
+sub inline_verify {
+ my ($self, $inlinesigned, $data, @certs) = @_;
+
+ return OPENPGP_UNSUPPORTED_SUBCMD;
+}
+
+sub verify {
+ my ($self, $data, $sig, @certs) = @_;
+
+ return OPENPGP_UNSUPPORTED_SUBCMD;
+}
+
+sub inline_sign {
+ my ($self, $data, $inlinesigned, $key) = @_;
+
+ return OPENPGP_UNSUPPORTED_SUBCMD;
+}
+
+1;
diff --git a/scripts/Dpkg/OpenPGP/Backend/GnuPG.pm b/scripts/Dpkg/OpenPGP/Backend/GnuPG.pm
new file mode 100644
index 0000000..7cc7bc7
--- /dev/null
+++ b/scripts/Dpkg/OpenPGP/Backend/GnuPG.pm
@@ -0,0 +1,301 @@
+# Copyright © 207, 2022 Guillem Jover <guillem@debian.org>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# 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. If not, see <https://www.gnu.org/licenses/>.
+
+package Dpkg::OpenPGP::Backend::GnuPG;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.01';
+
+use POSIX qw(:sys_wait_h);
+use File::Temp;
+use MIME::Base64;
+
+use Dpkg::ErrorHandling;
+use Dpkg::IPC;
+use Dpkg::File;
+use Dpkg::Path qw(find_command);
+use Dpkg::OpenPGP::ErrorCodes;
+
+use parent qw(Dpkg::OpenPGP::Backend);
+
+sub DEFAULT_CMDV {
+ return [ qw(gpgv) ];
+}
+
+sub DEFAULT_CMDSTORE {
+ return [ qw(gpg-agent) ];
+}
+
+sub DEFAULT_CMD {
+ return [ qw(gpg) ];
+}
+
+sub has_backend_cmd {
+ my $self = shift;
+
+ return defined $self->{cmd} && defined $self->{cmdstore};
+}
+
+sub has_keystore {
+ my $self = shift;
+
+ return 0 if not defined $self->{cmdstore};
+ return 1 if ($ENV{GNUPGHOME} && -e $ENV{GNUPGHOME}) ||
+ ($ENV{HOME} && -e "$ENV{HOME}/.gnupg");
+ return 0;
+}
+
+sub can_use_key {
+ my ($self, $key) = @_;
+
+ # With gpg, a secret key always requires gpg-agent (the key store).
+ return $self->has_keystore();
+}
+
+sub has_verify_cmd {
+ my $self = shift;
+
+ return defined $self->{cmdv} || defined $self->{cmd};
+}
+
+sub get_trusted_keyrings {
+ my $self = shift;
+
+ my @keyrings;
+ if (length $ENV{HOME} and -r "$ENV{HOME}/.gnupg/trustedkeys.gpg") {
+ push @keyrings, "$ENV{HOME}/.gnupg/trustedkeys.gpg";
+ }
+ return @keyrings;
+}
+
+# _pgp_* functions are strictly for applying or removing ASCII armor.
+# See <https://datatracker.ietf.org/doc/html/rfc4880#section-6> for more
+# details.
+#
+# Note that these _pgp_* functions are only necessary while relying on
+# gpgv, and gpgv itself does not verify multiple signatures correctly
+# (see https://bugs.debian.org/1010955).
+
+sub _pgp_dearmor_data {
+ my ($type, $data) = @_;
+
+ # Note that we ignore an incorrect or absent checksum, following the
+ # guidance of
+ # <https://datatracker.ietf.org/doc/draft-ietf-openpgp-crypto-refresh/>.
+ my $armor_regex = qr{
+ -----BEGIN\ PGP\ \Q$type\E-----[\r\t ]*\n
+ (?:[^:]+:\ [^\n]*[\r\t ]*\n)*
+ [\r\t ]*\n
+ ([a-zA-Z0-9/+\n]+={0,2})[\r\t ]*\n
+ (?:=[a-zA-Z0-9/+]{4}[\r\t ]*\n)?
+ -----END\ PGP\ \Q$type\E-----
+ }xm;
+
+ if ($data =~ m/$armor_regex/) {
+ return decode_base64($1);
+ }
+ return;
+}
+
+sub _pgp_armor_checksum {
+ my ($data) = @_;
+
+ # From the upcoming revision to RFC 4880
+ # <https://datatracker.ietf.org/doc/draft-ietf-openpgp-crypto-refresh/>.
+ #
+ # The resulting three-octet-wide value then gets base64-encoded into
+ # four base64 ASCII characters.
+
+ my $CRC24_INIT = 0xB704CE;
+ my $CRC24_GENERATOR = 0x864CFB;
+
+ my @bytes = unpack 'C*', $data;
+ my $crc = $CRC24_INIT;
+ for my $b (@bytes) {
+ $crc ^= ($b << 16);
+ for (1 .. 8) {
+ $crc <<= 1;
+ if ($crc & 0x1000000) {
+ # Clear bit 25 to avoid overflow.
+ $crc &= 0xffffff;
+ $crc ^= $CRC24_GENERATOR;
+ }
+ }
+ }
+ my $sum = pack 'CCC', ($crc >> 16) & 0xff, ($crc >> 8) & 0xff, $crc & 0xff;
+ return encode_base64($sum, q{});
+}
+
+sub _pgp_armor_data {
+ my ($type, $data) = @_;
+
+ my $out = encode_base64($data, q{}) =~ s/(.{1,64})/$1\n/gr;
+ chomp $out;
+ my $crc = _pgp_armor_checksum($data);
+ my $armor = <<~"ARMOR";
+ -----BEGIN PGP $type-----
+
+ $out
+ =$crc
+ -----END PGP $type-----
+ ARMOR
+ return $armor;
+}
+
+sub armor {
+ my ($self, $type, $in, $out) = @_;
+
+ my $raw_data = file_slurp($in);
+ my $data = _pgp_dearmor_data($type, $raw_data) // $raw_data;
+ my $armor = _pgp_armor_data($type, $data);
+ return OPENPGP_BAD_DATA unless defined $armor;
+ file_dump($out, $armor);
+
+ return OPENPGP_OK;
+}
+
+sub dearmor {
+ my ($self, $type, $in, $out) = @_;
+
+ my $armor = file_slurp($in);
+ my $data = _pgp_dearmor_data($type, $armor);
+ return OPENPGP_BAD_DATA unless defined $data;
+ file_dump($out, $data);
+
+ return OPENPGP_OK;
+}
+
+sub _gpg_exec
+{
+ my ($self, @exec) = @_;
+
+ my ($stdout, $stderr);
+ spawn(exec => \@exec, wait_child => 1, nocheck => 1, timeout => 10,
+ to_string => \$stdout, error_to_string => \$stderr);
+ if (WIFEXITED($?)) {
+ my $status = WEXITSTATUS($?);
+ print { *STDERR } "$stdout$stderr" if $status;
+ return $status;
+ } else {
+ subprocerr("@exec");
+ }
+}
+
+sub _gpg_options_weak_digests {
+ my @gpg_weak_digests = map {
+ (qw(--weak-digest), $_)
+ } qw(SHA1 RIPEMD160);
+
+ return @gpg_weak_digests;
+}
+
+sub _gpg_verify {
+ my ($self, $signeddata, $sig, $data, @certs) = @_;
+
+ return OPENPGP_MISSING_CMD if ! $self->has_verify_cmd();
+
+ my $gpg_home = File::Temp->newdir('dpkg-gpg-verify.XXXXXXXX', TMPDIR => 1);
+ my @cmd_opts = qw(--no-options --no-default-keyring --batch --quiet);
+ my @gpg_opts;
+ push @gpg_opts, _gpg_options_weak_digests();
+ push @gpg_opts, '--homedir', $gpg_home;
+ push @cmd_opts, @gpg_opts;
+
+ my @exec;
+ if ($self->{cmdv}) {
+ push @exec, $self->{cmdv};
+ push @exec, @gpg_opts;
+ # We need to touch the trustedkeys.gpg keyring, otherwise gpgv will
+ # emit an error about the trustedkeys.kbx file being of unknown type.
+ file_touch("$gpg_home/trustedkeys.gpg");
+ } else {
+ push @exec, $self->{cmd};
+ push @exec, @cmd_opts;
+ }
+ foreach my $cert (@certs) {
+ my $certring = File::Temp->new(UNLINK => 1, SUFFIX => '.pgp');
+ my $rc;
+ # XXX: The internal dearmor() does not handle concatenated ASCII Armor,
+ # but the old implementation handled such certificate keyrings, so to
+ # avoid regressing for now, we fallback to use the GnuPG dearmor.
+ if (defined $self->{cmd}) {
+ $rc = $self->_gpg_exec($self->{cmd}, @cmd_opts, '--yes',
+ '--output', $certring,
+ '--dearmor', $cert);
+ } else {
+ $rc = $self->dearmor('PUBLIC KEY BLOCK', $cert, $certring);
+ }
+ $certring = $cert if $rc;
+ push @exec, '--keyring', $certring;
+ }
+ push @exec, '--output', $data if defined $data;
+ if (! $self->{cmdv}) {
+ push @exec, '--verify';
+ }
+ push @exec, $sig if defined $sig;
+ push @exec, $signeddata;
+
+ my $rc = $self->_gpg_exec(@exec);
+ return OPENPGP_NO_SIG if $rc;
+ return OPENPGP_OK;
+}
+
+sub inline_verify {
+ my ($self, $inlinesigned, $data, @certs) = @_;
+
+ return $self->_gpg_verify($inlinesigned, undef, $data, @certs);
+}
+
+sub verify {
+ my ($self, $data, $sig, @certs) = @_;
+
+ return $self->_gpg_verify($data, $sig, undef, @certs);
+}
+
+sub inline_sign {
+ my ($self, $data, $inlinesigned, $key) = @_;
+
+ return OPENPGP_MISSING_CMD if ! $self->has_backend_cmd();
+
+ my @exec = ($self->{cmd});
+ push @exec, _gpg_options_weak_digests();
+ push @exec, qw(--utf8-strings --textmode --armor);
+ # Set conformance level.
+ push @exec, '--openpgp';
+ # Set secure algorithm preferences.
+ push @exec, '--personal-digest-preferences', 'SHA512 SHA384 SHA256 SHA224';
+ if ($key->type eq 'keyfile') {
+ # Promote the keyfile keyhandle to a keystore, this way we share the
+ # same gpg-agent and can get any password cached.
+ my $gpg_home = File::Temp->newdir('dpkg-sign.XXXXXXXX', TMPDIR => 1);
+
+ push @exec, '--homedir', $gpg_home;
+ $self->_gpg_exec(@exec, qw(--quiet --no-tty --batch --import), $key->handle);
+ $key->set('keystore', $gpg_home);
+ } elsif ($key->type eq 'keystore') {
+ push @exec, '--homedir', $key->handle;
+ } else {
+ push @exec, '--local-user', $key->handle;
+ }
+ push @exec, '--output', $inlinesigned;
+
+ my $rc = $self->_gpg_exec(@exec, '--clearsign', $data);
+ return OPENPGP_KEY_CANNOT_SIGN if $rc;
+ return OPENPGP_OK;
+}
+
+1;
diff --git a/scripts/Dpkg/OpenPGP/Backend/SOP.pm b/scripts/Dpkg/OpenPGP/Backend/SOP.pm
new file mode 100644
index 0000000..1c5073b
--- /dev/null
+++ b/scripts/Dpkg/OpenPGP/Backend/SOP.pm
@@ -0,0 +1,108 @@
+# Copyright © 2021-2022 Guillem Jover <guillem@debian.org>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# 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. If not, see <https://www.gnu.org/licenses/>.
+
+package Dpkg::OpenPGP::Backend::SOP;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.01';
+
+use POSIX qw(:sys_wait_h);
+
+use Dpkg::ErrorHandling;
+use Dpkg::IPC;
+use Dpkg::OpenPGP::ErrorCodes;
+
+use parent qw(Dpkg::OpenPGP::Backend);
+
+# - Once "gosop" implements inline-verify and inline-sign, add as alternative.
+# Ref: https://github.com/ProtonMail/gosop/issues/6
+# - Once "hop" implements the new SOP draft, add as alternative.
+# Ref: https://salsa.debian.org/clint/hopenpgp-tools/-/issues/4
+# - Once the SOP MR !23 is finalized and merged, implement a way to select
+# whether the SOP instance supports the expected draft.
+# Ref: https://gitlab.com/dkg/openpgp-stateless-cli/-/merge_requests/23
+# - Once the SOP issue #42 is resolved we can perhaps remove the alternative
+# dependencies and commands to check?
+# Ref: https://gitlab.com/dkg/openpgp-stateless-cli/-/issues/42
+
+sub DEFAULT_CMD {
+ return [ qw(sqop pgpainless-cli) ];
+}
+
+sub _sop_exec
+{
+ my ($self, $io, @exec) = @_;
+
+ return OPENPGP_MISSING_CMD unless $self->{cmd};
+
+ $io->{out} //= '/dev/null';
+ my $stderr;
+ spawn(exec => [ $self->{cmd}, @exec ],
+ wait_child => 1, nocheck => 1, timeout => 10,
+ from_file => $io->{in}, to_file => $io->{out},
+ error_to_string => \$stderr);
+ if (WIFEXITED($?)) {
+ my $status = WEXITSTATUS($?);
+ print { *STDERR } "$stderr" if $status;
+ return $status;
+ } else {
+ subprocerr("$self->{cmd} @exec");
+ }
+}
+
+sub armor
+{
+ my ($self, $type, $in, $out) = @_;
+
+ # We ignore the $type, and let "sop" handle this automatically.
+ return $self->_sop_exec({ in => $in, out => $out }, 'armor');
+}
+
+sub dearmor
+{
+ my ($self, $type, $in, $out) = @_;
+
+ # We ignore the $type, and let "sop" handle this automatically.
+ return $self->_sop_exec({ in => $in, out => $out }, 'dearmor');
+}
+
+sub inline_verify
+{
+ my ($self, $inlinesigned, $data, @certs) = @_;
+
+ return $self->_sop_exec({ in => $inlinesigned, out => $data },
+ 'inline-verify', @certs);
+}
+
+sub verify
+{
+ my ($self, $data, $sig, @certs) = @_;
+
+ return $self->_sop_exec({ in => $data }, 'verify', $sig, @certs);
+}
+
+sub inline_sign
+{
+ my ($self, $data, $inlinesigned, $key) = @_;
+
+ return OPENPGP_NEEDS_KEYSTORE if $key->needs_keystore();
+
+ return $self->_sop_exec({ in => $data, out => $inlinesigned },
+ qw(inline-sign --as clearsigned --), $key->handle);
+}
+
+1;
diff --git a/scripts/Dpkg/OpenPGP/Backend/Sequoia.pm b/scripts/Dpkg/OpenPGP/Backend/Sequoia.pm
new file mode 100644
index 0000000..5313393
--- /dev/null
+++ b/scripts/Dpkg/OpenPGP/Backend/Sequoia.pm
@@ -0,0 +1,123 @@
+# Copyright © 2021-2022 Guillem Jover <guillem@debian.org>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# 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. If not, see <https://www.gnu.org/licenses/>.
+
+package Dpkg::OpenPGP::Backend::Sequoia;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.01';
+
+use POSIX qw(:sys_wait_h);
+
+use Dpkg::ErrorHandling;
+use Dpkg::IPC;
+use Dpkg::OpenPGP::ErrorCodes;
+
+use parent qw(Dpkg::OpenPGP::Backend);
+
+sub DEFAULT_CMD {
+ return [ qw(sq) ];
+}
+
+sub _sq_exec
+{
+ my ($self, @exec) = @_;
+
+ my ($stdout, $stderr);
+ spawn(exec => [ $self->{cmd}, @exec ],
+ wait_child => 1, nocheck => 1, timeout => 10,
+ to_string => \$stdout, error_to_string => \$stderr);
+ if (WIFEXITED($?)) {
+ my $status = WEXITSTATUS($?);
+ print { *STDERR } "$stdout$stderr" if $status;
+ return $status;
+ } else {
+ subprocerr("$self->{cmd} @exec");
+ }
+}
+
+sub armor
+{
+ my ($self, $type, $in, $out) = @_;
+
+ return OPENPGP_MISSING_CMD unless $self->{cmd};
+
+ # We ignore the $type, and let "sq" handle this automatically.
+ my $rc = $self->_sq_exec(qw(armor --output), $out, $in);
+ return OPENPGP_BAD_DATA if $rc;
+ return OPENPGP_OK;
+}
+
+sub dearmor
+{
+ my ($self, $type, $in, $out) = @_;
+
+ return OPENPGP_MISSING_CMD unless $self->{cmd};
+
+ # We ignore the $type, and let "sq" handle this automatically.
+ my $rc = $self->_sq_exec(qw(dearmor --output), $out, $in);
+ return OPENPGP_BAD_DATA if $rc;
+ return OPENPGP_OK;
+}
+
+sub inline_verify
+{
+ my ($self, $inlinesigned, $data, @certs) = @_;
+
+ return OPENPGP_MISSING_CMD unless $self->{cmd};
+
+ my @opts;
+ push @opts, map { ('--signer-cert', $_) } @certs;
+ push @opts, '--output', $data if defined $data;
+
+ my $rc = $self->_sq_exec(qw(verify), @opts, $inlinesigned);
+ return OPENPGP_NO_SIG if $rc;
+ return OPENPGP_OK;
+}
+
+sub verify
+{
+ my ($self, $data, $sig, @certs) = @_;
+
+ return OPENPGP_MISSING_CMD unless $self->{cmd};
+
+ my @opts;
+ push @opts, map { ('--signer-cert', $_) } @certs;
+ push @opts, '--detached', $sig;
+
+ my $rc = $self->_sq_exec(qw(verify), @opts, $data);
+ return OPENPGP_NO_SIG if $rc;
+ return OPENPGP_OK;
+}
+
+sub inline_sign
+{
+ my ($self, $data, $inlinesigned, $key) = @_;
+
+ return OPENPGP_MISSING_CMD unless $self->{cmd};
+ return OPENPGP_NEEDS_KEYSTORE if $key->needs_keystore();
+
+ my @opts;
+ push @opts, '--cleartext-signature';
+ push @opts, '--signer-key', $key->handle;
+ push @opts, '--output', $inlinesigned;
+
+ my $rc = $self->_sq_exec('sign', @opts, $data);
+ return OPENPGP_KEY_CANNOT_SIGN if $rc;
+ return OPENPGP_OK;
+}
+
+1;
diff --git a/scripts/Dpkg/OpenPGP/ErrorCodes.pm b/scripts/Dpkg/OpenPGP/ErrorCodes.pm
new file mode 100644
index 0000000..67bd39b
--- /dev/null
+++ b/scripts/Dpkg/OpenPGP/ErrorCodes.pm
@@ -0,0 +1,93 @@
+# Copyright © 2022 Guillem Jover <guillem@debian.org>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# 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. If not, see <https://www.gnu.org/licenses/>.
+
+package Dpkg::OpenPGP::ErrorCodes;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.01';
+our @EXPORT = qw(
+ OPENPGP_OK
+ OPENPGP_NO_SIG
+ OPENPGP_MISSING_ARG
+ OPENPGP_UNSUPPORTED_OPTION
+ OPENPGP_BAD_DATA
+ OPENPGP_EXPECTED_TEXT
+ OPENPGP_OUTPUT_EXISTS
+ OPENPGP_MISSING_INPUT
+ OPENPGP_KEY_IS_PROTECTED
+ OPENPGP_UNSUPPORTED_SUBCMD
+ OPENPGP_KEY_CANNOT_SIGN
+
+ OPENPGP_MISSING_CMD
+ OPENPGP_NEEDS_KEYSTORE
+
+ openpgp_errorcode_to_string
+);
+
+use Exporter qw(import);
+
+use Dpkg::Gettext;
+
+# Error codes based on
+# https://ietf.org/archive/id/draft-dkg-openpgp-stateless-cli-04.html#section-6
+#
+# Local error codes use a negative number, as that should not conflict with
+# the SOP exit codes.
+
+use constant {
+ OPENPGP_OK => 0,
+ OPENPGP_NO_SIG => 3,
+ OPENPGP_MISSING_ARG => 19,
+ OPENPGP_UNSUPPORTED_OPTION => 37,
+ OPENPGP_BAD_DATA => 41,
+ OPENPGP_EXPECTED_TEXT => 53,
+ OPENPGP_OUTPUT_EXISTS => 59,
+ OPENPGP_MISSING_INPUT => 61,
+ OPENPGP_KEY_IS_PROTECTED => 67,
+ OPENPGP_UNSUPPORTED_SUBCMD => 69,
+ OPENPGP_KEY_CANNOT_SIGN => 79,
+
+ OPENPGP_MISSING_CMD => -1,
+ OPENPGP_NEEDS_KEYSTORE => -2,
+};
+
+my %code2error = (
+ OPENPGP_OK() => N_('success'),
+ OPENPGP_NO_SIG() => N_('no acceptable signature found'),
+ OPENPGP_MISSING_ARG() => N_('missing required argument'),
+ OPENPGP_UNSUPPORTED_OPTION() => N_('unsupported option'),
+ OPENPGP_BAD_DATA() => N_('invalid data type'),
+ OPENPGP_EXPECTED_TEXT() => N_('non-text input where text expected'),
+ OPENPGP_OUTPUT_EXISTS() => N_('output file already exists'),
+ OPENPGP_MISSING_INPUT() => N_('input file does not exist'),
+ OPENPGP_KEY_IS_PROTECTED() => N_('cannot unlock password-protected key'),
+ OPENPGP_UNSUPPORTED_SUBCMD() => N_('unsupported subcommand'),
+ OPENPGP_KEY_CANNOT_SIGN() => N_('key is not signature-capable'),
+
+ OPENPGP_MISSING_CMD() => N_('missing OpenPGP implementation'),
+ OPENPGP_NEEDS_KEYSTORE() => N_('specified key needs a keystore'),
+);
+
+sub openpgp_errorcode_to_string
+{
+ my $code = shift;
+
+ return gettext($code2error{$code}) if exists $code2error{$code};
+ return sprintf g_('error code %d'), $code;
+}
+
+1;
diff --git a/scripts/Dpkg/OpenPGP/KeyHandle.pm b/scripts/Dpkg/OpenPGP/KeyHandle.pm
new file mode 100644
index 0000000..89ae448
--- /dev/null
+++ b/scripts/Dpkg/OpenPGP/KeyHandle.pm
@@ -0,0 +1,103 @@
+# Copyright © 2022 Guillem Jover <guillem@debian.org>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# 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. If not, see <https://www.gnu.org/licenses/>.
+
+package Dpkg::OpenPGP::KeyHandle;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.01';
+
+use Carp;
+use List::Util qw(any none);
+
+sub new {
+ my ($this, %opts) = @_;
+ my $class = ref($this) || $this;
+
+ my $self = {
+ type => $opts{type} // 'auto',
+ handle => $opts{handle},
+ };
+ bless $self, $class;
+
+ $self->_sanitize();
+
+ return $self;
+}
+
+my $keyid_regex = qr/^(?:0x)?([[:xdigit:]]+)$/;
+
+sub _sanitize {
+ my ($self) = shift;
+
+ my $type = $self->{type};
+ if ($type eq 'auto') {
+ if (-e $self->{handle}) {
+ $type = 'keyfile';
+ } else {
+ $type = 'autoid';
+ }
+ }
+
+ if ($type eq 'autoid') {
+ if ($self->{handle} =~ m/$keyid_regex/) {
+ $self->{handle} = $1;
+ $type = 'keyid';
+ } else {
+ $type = 'userid';
+ }
+ $self->{type} = $type;
+ } elsif ($type eq 'keyid') {
+ if ($self->{handle} =~ m/$keyid_regex/) {
+ $self->{handle} = $1;
+ }
+ }
+
+ if (none { $type eq $_ } qw(userid keyid keyfile keystore)) {
+ croak "unknown type parameter value $type";
+ }
+
+ return;
+}
+
+sub needs_keystore {
+ my $self = shift;
+
+ return any { $self->{type} eq $_ } qw(keyid userid);
+}
+
+sub set {
+ my ($self, $type, $handle) = @_;
+
+ $self->{type} = $type;
+ $self->{handle} = $handle;
+
+ $self->_sanitize();
+}
+
+sub type {
+ my $self = shift;
+
+ return $self->{type};
+}
+
+sub handle {
+ my $self = shift;
+
+ return $self->{handle};
+}
+
+1;