diff options
Diffstat (limited to 'rt/lib/RT/Crypt.pm')
-rw-r--r-- | rt/lib/RT/Crypt.pm | 843 |
1 files changed, 843 insertions, 0 deletions
diff --git a/rt/lib/RT/Crypt.pm b/rt/lib/RT/Crypt.pm new file mode 100644 index 0000000..cad86d2 --- /dev/null +++ b/rt/lib/RT/Crypt.pm @@ -0,0 +1,843 @@ +# BEGIN BPS TAGGED BLOCK {{{ +# +# COPYRIGHT: +# +# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC +# <sales@bestpractical.com> +# +# (Except where explicitly superseded by other copyright notices) +# +# +# LICENSE: +# +# This work is made available to you under the terms of Version 2 of +# the GNU General Public License. A copy of that license should have +# been provided with this software, but in any event can be snarfed +# from www.gnu.org. +# +# This work 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 or visit their web page on the internet at +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. +# +# +# CONTRIBUTION SUBMISSION POLICY: +# +# (The following paragraph is not intended to limit the rights granted +# to you to modify and distribute this software under the terms of +# the GNU General Public License and is only of importance to you if +# you choose to contribute your changes and enhancements to the +# community by submitting them to Best Practical Solutions, LLC.) +# +# By intentionally submitting any modifications, corrections or +# derivatives to this work, or any other work intended for use with +# Request Tracker, to Best Practical Solutions, LLC, you confirm that +# you are the copyright holder for those contributions and you grant +# Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable, +# royalty-free, perpetual, license to use, copy, create derivative +# works based on those contributions, and sublicense and distribute +# those contributions and any derivatives thereof. +# +# END BPS TAGGED BLOCK }}} + +use strict; +use warnings; + +package RT::Crypt; +use 5.010; + +=head1 NAME + +RT::Crypt - encrypt/decrypt and sign/verify subsystem for RT + +=head1 DESCRIPTION + +This module provides support for encryption and signing of outgoing +messages, as well as the decryption and verification of incoming emails +using various encryption standards. Currently, L<GnuPG|RT::Crypt::GnuPG> +and L<SMIME|RT::Crypt::SMIME> protocols are supported. + +=head1 CONFIGURATION + +You can control the configuration of this subsystem from RT's configuration file. +Some options are available via the web interface, but to enable this functionality, +you MUST start in the configuration file. + +For each protocol there is a hash with the same name in the configuration file. +This hash controls RT-specific options regarding the protocol. It allows you to +enable/disable each facility or change the format of messages; for example, GnuPG +uses the following config: + + Set( %GnuPG, + Enable => 1, + ... other options ... + ); + +C<Enable> is the only key that is generic for all protocols. A protocol may have +additional options to fine-tune behaviour. + +However, note that you B<must> add the +L<Auth::Crypt|RT::Interface::Email::Auth::Crypt> email filter to enable +the handling of incoming encrypted/signed messages. It should be added +in addition to the standard +L<Auth::MailFrom|RT::Interface::Email::Auth::Crypt> plugin. + +=head2 %Crypt + +This config option hash chooses which protocols are decrypted and +verified in incoming messages, which protocol is used for outgoing +emails, and RT's behaviour on errors during decrypting and verification. + +RT will provide sane defaults for all of these options. By default, all +enabled encryption protocols are decrypted on incoming mail; if you wish +to limit this to a subset, you may, via: + + Set( %Crypt, + ... + Incoming => ['SMIME'], + ... + ); + +RT can currently only use one protocol to encrypt and sign outgoing +email; this defaults to the first enabled protocol. You many specify it +explicitly via: + + Set( %Crypt, + ... + Outgoing => 'GnuPG', + ... + ); + +You can allow users to encrypt data in the database by setting the +C<AllowEncryptDataInDB> key to a true value; by default, this is +disabled. Be aware that users must have rights to see and modify +tickets to use this feature. + +=head2 Per-queue options + +Using the web interface, it is possible to enable signing and/or +encrypting by default. As an administrative user of RT, navigate to the +'Admin' and 'Queues' menus, and select a queue. If at least one +encryption protocol is enabled, information concerning available keys +will be displayed, as well as options to enable signing and encryption. + +=head2 Handling incoming messages + +To enable handling of encrypted and signed message in the RT you must +enable the L<RT::Interface::Email::Auth::Crypt> mail plugin: + + Set(@MailPlugins, 'Auth::MailFrom', 'Auth::Crypt', ...other filter...); + +=head2 Error handling + +There are several global templates created in the database by +default. RT uses these templates to send error messages to users or RT's +owner. These templates have an 'Error:' or 'Error to RT owner:' prefix +in the name. You can adjust the text of the messages using the web +interface. + +Note that while C<$TicketObj>, C<$TransactionObj> and other variables +usually available in RT's templates are not available in these +templates, but each is passed alternate data structures can be used to +build better messages; see the default templates and descriptions below. + +You can disable any particular notification by simply deleting the +content of a template. Deleting the templates entirely is not +suggested, as RT will log error messages when attempting to send mail +usign them. + +=head3 Problems with public keys + +The 'Error: public key' template is used to inform the user that RT had +problems with their public key, and thus will not be able to send +encrypted content. There are several reasons why RT might fail to use a +key; by default, the actual reason is not sent to the user, but sent to +the RT owner using the 'Error to RT owner: public key' template. + +Possible reasons include "Not Found", "Ambiguous specification", "Wrong +key usage", "Key revoked", "Key expired", "No CRL known", "CRL too old", +"Policy mismatch", "Not a secret key", "Key not trusted" or "No specific +reason given". + +In the 'Error: public key' template there are a few additional variables +available: + +=over 4 + +=item $Message - user friendly error message + +=item $Reason - short reason as listed above + +=item $Recipient - recipient's identification + +=item $AddressObj - L<Email::Address> object containing recipient's email address + +=back + +As a message may have several invalid recipients, to avoid sending many +emails to the RT owner, the system sends one message to the owner, +grouped by recipient. In the 'Error to RT owner: public key' template a +C<@BadRecipients> array is available where each element is a hash +reference that describes one recipient using the same fields as +described above: + + @BadRecipients = ( + { Message => '...', Reason => '...', Recipient => '...', ...}, + { Message => '...', Reason => '...', Recipient => '...', ...}, + ... + ) + +=head3 Private key doesn't exist + +The 'Error: no private key' template is used to inform the user that +they sent an encrypted email to RT, but RT does not have the private key +to decrypt it. + +In this template L<MIME::Entity> object C<$Message> is available, which +is the originally received message. + +=head3 Invalid data + +The 'Error: bad encrypted data' template is used to inform the user that +a message they sent had invalid data, and could not be handled. There +are several possible reasons for this error, but most of them are data +corruption or absence of expected information. + +In this template, the C<@Messages> array is available, and will contain +a list of error messages. + +=head1 METHODS + +=head2 Protocols + +Returns the complete set of encryption protocols that RT implements; not +all may be supported by this installation. + +=cut + +our @PROTOCOLS = ('GnuPG', 'SMIME'); +our %PROTOCOLS = map { lc $_ => $_ } @PROTOCOLS; + +sub Protocols { + return @PROTOCOLS; +} + +=head2 EnabledProtocols + +Returns the set of enabled and available encryption protocols. + +=cut + +sub EnabledProtocols { + my $self = shift; + return grep RT->Config->Get($_)->{'Enable'}, $self->Protocols; +} + +=head2 UseForOutgoing + +Returns the configured outgoing encryption protocol; see +L<RT_Config/Crypt>. + +=cut + +sub UseForOutgoing { + return RT->Config->Get('Crypt')->{'Outgoing'}; +} + +=head2 EnabledOnIncoming + +Returns the list of encryption protocols that should be used for +decryption and verification of incoming email; see L<RT_Config/Crypt>. +This list is irrelevant unless L<RT::Interface::Email::Auth::Crypt> is +enabled in L<RT_Config/@MailPlugins>. + +=cut + +sub EnabledOnIncoming { + return @{ scalar RT->Config->Get('Crypt')->{'Incoming'} }; +} + +=head2 LoadImplementation CLASS + +Given the name of an encryption implementation (e.g. "GnuPG"), loads the +L<RT::Crypt> class associated with it; return the classname on success, +and undef on failure. + +=cut + +sub LoadImplementation { + state %cache; + my $proto = $PROTOCOLS{ lc $_[1] } or die "Unknown protocol '$_[1]'"; + my $class = 'RT::Crypt::'. $proto; + return $cache{ $class } if exists $cache{ $class }; + + if ($class->require) { + return $cache{ $class } = $class; + } else { + RT->Logger->warn( "Could not load $class: $@" ); + return $cache{ $class } = undef; + } +} + +=head2 SimpleImplementationCall Protocol => NAME, [...] + +Examines the caller of this method, and dispatches to the method of the +same name on the correct L<RT::Crypt::Role> class based on the provided +C<Protocol>. + +=cut + +sub SimpleImplementationCall { + my $self = shift; + my %args = (@_); + my $protocol = delete $args{'Protocol'} || $self->UseForOutgoing; + + my $method = (caller(1))[3]; + $method =~ s/.*:://; + + my %res = $self->LoadImplementation( $protocol )->$method( %args ); + $res{'Protocol'} = $protocol if keys %res; + return %res; +} + +=head2 FindProtectedParts Entity => MIME::Entity + +Looks for encrypted or signed parts of the given C<Entity>, using all +L</EnabledOnIncoming> encryption protocols. For each node in the MIME +hierarchy, L<RT::Crypt::Role/CheckIfProtected> for that L<MIME::Entity> +is called on each L</EnabledOnIncoming> protocol. Any multipart nodes +not claimed by those protocols are recursed into. + +Finally, L<RT::Crypt::Role/FindScatteredParts> is called on the top-most +entity for each L</EnabledOnIncoming> protocol. + +Returns a list of hash references; each hash reference is guaranteed to +contain a C<Protocol> key describing the protocol of the found part, and +a C<Type> which is either C<encrypted> or C<signed>. The remaining keys +are protocol-dependent; the hashref will be provided to +L</VerifyDecrypt>. + +=cut + +sub FindProtectedParts { + my $self = shift; + my %args = ( + Entity => undef, + Skip => {}, + Scattered => 1, + @_ + ); + + my $entity = $args{'Entity'}; + return () if $args{'Skip'}{ $entity }; + + $args{'TopEntity'} ||= $entity; + + my @protocols = $self->EnabledOnIncoming; + + foreach my $protocol ( @protocols ) { + my $class = $self->LoadImplementation( $protocol ); + my %info = $class->CheckIfProtected( + TopEntity => $args{'TopEntity'}, + Entity => $entity, + ); + next unless keys %info; + + $args{'Skip'}{ $entity } = 1; + $info{'Protocol'} = $protocol; + return \%info; + } + + if ( $entity->effective_type =~ /^multipart\/(?:signed|encrypted)/ ) { + # if no module claimed that it supports these types then + # we don't dive in and check sub-parts + $args{'Skip'}{ $entity } = 1; + return (); + } + + my @res; + + # not protected itself, look inside + push @res, $self->FindProtectedParts( + %args, Entity => $_, Scattered => 0, + ) foreach grep !$args{'Skip'}{$_}, $entity->parts; + + if ( $args{'Scattered'} ) { + my %parent; + my $filter; $filter = sub { + $parent{$_[0]} = $_[1]; + unless ( $_[0]->is_multipart ) { + return () if $args{'Skip'}{$_[0]}; + return $_[0]; + } + return map $filter->($_, $_[0]), grep !$args{'Skip'}{$_}, $_[0]->parts; + }; + my @parts = $filter->($entity); + return @res unless @parts; + + foreach my $protocol ( @protocols ) { + my $class = $self->LoadImplementation( $protocol ); + my @list = $class->FindScatteredParts( + Entity => $args{'TopEntity'}, + Parts => \@parts, + Parents => \%parent, + Skip => $args{'Skip'} + ); + next unless @list; + + $_->{'Protocol'} = $protocol foreach @list; + push @res, @list; + @parts = grep !$args{'Skip'}{$_}, @parts; + } + } + + return @res; +} + +=head2 SignEncrypt Entity => ENTITY, [Sign => 1], [Encrypt => 1], +[Recipients => ARRAYREF], [Signer => NAME], [Protocol => NAME], +[Passphrase => VALUE] + +Takes a L<MIME::Entity> object, and signs and/or encrypts it using the +given C<Protocol>. If not set, C<Recipients> for encryption will be set +by examining the C<To>, C<Cc>, and C<Bcc> headers of the MIME entity. +If not set, C<Signer> defaults to the C<From> of the MIME entity. + +C<Passphrase>, if not provided, will be retrieved using +L<RT::Crypt::Role/GetPassphrase>. + +Returns a hash with at least the following keys: + +=over + +=item exit_code + +True if there was an error encrypting or signing. + +=item message + +An un-localized error message desribing the problem. + +=back + +=cut + +sub SignEncrypt { + my $self = shift; + my %args = (@_); + + my $entity = $args{'Entity'}; + if ( $args{'Sign'} && !defined $args{'Signer'} ) { + $args{'Signer'} = + $self->UseKeyForSigning + || do { + my ($addr) = map {Email::Address->parse( Encode::decode( "UTF-8", $_ ) )} + $entity->head->get( 'From' ); + $addr ? $addr->address : undef + }; + } + if ( $args{'Encrypt'} && !$args{'Recipients'} ) { + my %seen; + $args{'Recipients'} = [ + grep $_ && !$seen{ $_ }++, map $_->address, + map Email::Address->parse( Encode::decode("UTF-8", $_ ) ), + map $entity->head->get( $_ ), + qw(To Cc Bcc) + ]; + } + return $self->SimpleImplementationCall( %args ); +} + +=head2 SignEncryptContent Content => STRINGREF, [Sign => 1], [Encrypt => 1], +[Recipients => ARRAYREF], [Signer => NAME], [Protocol => NAME], +[Passphrase => VALUE] + +Signs and/or encrypts a string, which is passed by reference. +C<Recipients> defaults to C</UseKeyForSigning>, and C<Recipients> +defaults to the global L<RT::Config/CorrespondAddress>. All other +arguments and return values are identical to L</SignEncrypt>. + +=cut + +sub SignEncryptContent { + my $self = shift; + my %args = (@_); + + if ( $args{'Sign'} && !defined $args{'Signer'} ) { + $args{'Signer'} = $self->UseKeyForSigning; + } + if ( $args{'Encrypt'} && !$args{'Recipients'} ) { + $args{'Recipients'} = [ RT->Config->Get('CorrespondAddress') ]; + } + + return $self->SimpleImplementationCall( %args ); +} + +=head2 DrySign Signer => KEY + +Signs a small message with the key, to make sure the key exists and we +have a useable passphrase. The Signer argument MUST be a key identifier +of the signer: either email address, key id or finger print. + +Returns a true value if all went well. + +=cut + +sub DrySign { + my $self = shift; + + my $mime = MIME::Entity->build( + Type => "text/plain", + From => 'nobody@localhost', + To => 'nobody@localhost', + Subject => "dry sign", + Data => ['t'], + ); + + my %res = $self->SignEncrypt( + @_, + Sign => 1, + Encrypt => 0, + Entity => $mime, + ); + + return $res{exit_code} == 0; +} + +=head2 VerifyDecrypt Entity => ENTITY [, Passphrase => undef ] + +Locates all protected parts of the L<MIME::Entity> object C<ENTITY>, as +found by L</FindProtectedParts>, and calls +L<RT::Crypt::Role/VerifyDecrypt> from the appropriate L<RT::Crypt::Role> +class on each. + +C<Passphrase>, if not provided, will be retrieved using +L<RT::Crypt::Role/GetPassphrase>. + +Returns a list of the hash references returned from +L<RT::Crypt::Role/VerifyDecrypt>. + +=cut + +sub VerifyDecrypt { + my $self = shift; + my %args = ( + Entity => undef, + Recursive => 1, + @_ + ); + + my @res; + + my @protected = $self->FindProtectedParts( Entity => $args{'Entity'} ); + foreach my $protected ( @protected ) { + my %res = $self->SimpleImplementationCall( + %args, Protocol => $protected->{'Protocol'}, Info => $protected + ); + + # Let the header be modified so continuations are handled + my $modify = $res{status_on}->head->modify; + $res{status_on}->head->modify(1); + $res{status_on}->head->add( + "X-RT-" . $protected->{'Protocol'} . "-Status" => $res{'status'} + ); + $res{status_on}->head->modify($modify); + + push @res, \%res; + } + + push @res, $self->VerifyDecrypt( %args ) + if $args{Recursive} and @res and not grep {$_->{'exit_code'}} @res; + + return @res; +} + +=head2 DecryptContent Protocol => NAME, Content => STRINGREF, [Passphrase => undef] + +Decrypts the content in the string reference in-place. All other +arguments and return values are identical to L</VerifyDecrypt>. + +=cut + +sub DecryptContent { + return shift->SimpleImplementationCall( @_ ); +} + +=head2 ParseStatus Protocol => NAME, Status => STRING + +Takes a C<String> describing the status of verification/decryption, +usually as stored in a MIME header. Parses it and returns array of hash +references, one for each operation. Each hashref contains at least +three keys: + +=over + +=item Operation + +The classification of the process whose status is being reported upon. +Valid values include C<Sign>, C<Encrypt>, C<Decrypt>, C<Verify>, +C<PassphraseCheck>, C<RecipientsCheck> and C<Data>. + +=item Status + +Whether the operation was successful; contains C<DONE> on success. +Other possible values include C<ERROR>, C<BAD>, or C<MISSING>. + +=item Message + +An un-localized user friendly message. + +=back + +=cut + +sub ParseStatus { + my $self = shift; + my %args = ( + Protocol => undef, + Status => '', + @_ + ); + return $self->LoadImplementation( $args{'Protocol'} )->ParseStatus( $args{'Status'} ); +} + +=head2 UseKeyForSigning [KEY] + +Returns or sets the identifier of the key that should be used for +signing. Returns the current value when called without arguments; sets +the new value when called with one argument and unsets if it's undef. + +This cache is cleared at the end of every request. + +=cut + +sub UseKeyForSigning { + my $self = shift; + state $key; + if ( @_ ) { + $key = $_[0]; + } + return $key; +} + +=head2 UseKeyForEncryption [KEY [, VALUE]] + +Gets or sets keys to use for encryption. When passed no arguments, +clears the cache. When passed just a key, returns the encryption key +previously stored for that key. When passed two (or more) keys, stores +them associatively. + +This cache is reset at the end of every request. + +=cut + +sub UseKeyForEncryption { + my $self = shift; + state %key; + unless ( @_ ) { + %key = (); + } elsif ( @_ > 1 ) { + %key = (%key, @_); + $key{ lc($_) } = delete $key{ $_ } foreach grep lc ne $_, keys %key; + } else { + return $key{ $_[0] }; + } + return (); +} + +=head2 GetKeysForEncryption Recipient => EMAIL, Protocol => NAME + +Returns the list of keys which are suitable for encrypting mail to the +given C<Recipient>. Generally this is equivalent to L</GetKeysInfo> +with a C<Type> of <private>, but encryption protocols may further limit +which keys can be used for encryption, as opposed to signing. + +=cut + +sub CheckRecipients { + my $self = shift; + my @recipients = (@_); + + my ($status, @issues) = (1, ()); + + my $trust = sub { 1 }; + if ( $self->UseForOutgoing eq 'SMIME' ) { + $trust = sub { $_[0]->{'TrustLevel'} > 0 or RT->Config->Get('SMIME')->{AcceptUntrustedCAs} }; + } elsif ( $self->UseForOutgoing eq 'GnuPG' ) { + $trust = sub { $_[0]->{'TrustLevel'} > 0 }; + } + + my %seen; + foreach my $address ( grep !$seen{ lc $_ }++, map $_->address, @recipients ) { + my %res = $self->GetKeysForEncryption( Recipient => $address ); + if ( $res{'info'} && @{ $res{'info'} } == 1 and $trust->($res{'info'}[0]) ) { + # One key, which is trusted, or we can sign with an + # untrusted key (aka SMIME with AcceptUntrustedCAs) + next; + } + my $user = RT::User->new( RT->SystemUser ); + $user->LoadByEmail( $address ); + # it's possible that we have no User record with the email + $user = undef unless $user->id; + + if ( my $fpr = RT::Crypt->UseKeyForEncryption( $address ) ) { + if ( $res{'info'} && @{ $res{'info'} } ) { + next if + grep lc $_->{'Fingerprint'} eq lc $fpr, + grep $trust->($_), + @{ $res{'info'} }; + } + + $status = 0; + my %issue = ( + EmailAddress => $address, + $user? (User => $user) : (), + Keys => undef, + ); + $issue{'Message'} = "Selected key either is not trusted or doesn't exist anymore."; #loc + push @issues, \%issue; + next; + } + + my $prefered_key; + $prefered_key = $user->PreferredKey if $user; + #XXX: prefered key is not yet implemented... + + # classify errors + $status = 0; + my %issue = ( + EmailAddress => $address, + $user? (User => $user) : (), + Keys => undef, + ); + + unless ( $res{'info'} && @{ $res{'info'} } ) { + # no key + $issue{'Message'} = "There is no key suitable for encryption."; #loc + } + elsif ( @{ $res{'info'} } == 1 && !$res{'info'}[0]{'TrustLevel'} ) { + # trust is not set + $issue{'Message'} = "There is one suitable key, but trust level is not set."; #loc + } + else { + # multiple keys + $issue{'Message'} = "There are several keys suitable for encryption."; #loc + } + push @issues, \%issue; + } + return ($status, @issues); +} + +sub GetKeysForEncryption { + my $self = shift; + my %args = @_%2? (Recipient => @_) : (Protocol => undef, Recipient => undef, @_ ); + return $self->SimpleImplementationCall( %args ); +} + +=head2 GetKeysForSigning Signer => EMAIL, Protocol => NAME + +Returns the list of keys which are suitable for signing mail from the +given C<Signer>. Generally this is equivalent to L</GetKeysInfo> +with a C<Type> of <private>, but encryption protocols may further limit +which keys can be used for signing, as opposed to encryption. + +=cut + +sub GetKeysForSigning { + my $self = shift; + my %args = @_%2? (Signer => @_) : (Protocol => undef, Signer => undef, @_); + return $self->SimpleImplementationCall( %args ); +} + +=head2 GetPublicKeyInfo Protocol => NAME, KEY => EMAIL + +As per L</GetKeyInfo>, but the C<Type> is forced to C<public>. + +=cut + +sub GetPublicKeyInfo { + return (shift)->GetKeyInfo( @_, Type => 'public' ); +} + +=head2 GetPrivateKeyInfo Protocol => NAME, KEY => EMAIL + +As per L</GetKeyInfo>, but the C<Type> is forced to C<private>. + +=cut + +sub GetPrivateKeyInfo { + return (shift)->GetKeyInfo( @_, Type => 'private' ); +} + +=head2 GetKeyInfo Protocol => NAME, Type => ('public'|'private'), KEY => EMAIL + +As per L</GetKeysInfo>, but only the first matching key is returned in +the C<info> value of the result. + +=cut + +sub GetKeyInfo { + my $self = shift; + my %res = $self->GetKeysInfo( @_ ); + $res{'info'} = $res{'info'}->[0]; + return %res; +} + +=head2 GetKeysInfo Protocol => NAME, Type => ('public'|'private'), Key => EMAIL + +Looks up information about the public or private keys (as determined by +C<Type>) for the email address C<Key>. As each protocol has its own key +store, C<Protocol> is also required. If no C<Key> is provided and a +true value for C<Force> is given, returns all keys. + +The return value is a hash containing C<exit_code> and C<message> in the +case of failure, or C<info>, which is an array reference of key +information. Each key is represented as a hash reference; the keys are +protocol-dependent, but will at least contain: + +=over + +=item Protocol + +The name of the protocol of this key + +=item Created + +An L<RT::Date> of the date the key was created; undef if unset. + +=item Expire + +An L<RT::Date> of the date the key expires; undef if the key does not expire. + +=item Fingerprint + +A fingerprint unique to this key + +=item Formatted + +A formatted string representation of the key + +=item User + +An array reference of associated user data, each of which is a hashref +containing at least a C<String> value, which is a C<< Alice Example +<alice@example.com> >> style email address. Each may also contain +C<Created> and C<Expire> keys, which are L<RT::Date> objects. + +=back + +=cut + +sub GetKeysInfo { + my $self = shift; + my %args = @_%2 ? (Key => @_) : ( Protocol => undef, Key => undef, @_ ); + return $self->SimpleImplementationCall( %args ); +} + +1; |