Eduard.pm 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. package App::Eduard;
  2. use 5.014000;
  3. use strict;
  4. use warnings;
  5. use parent qw/Exporter/;
  6. our $VERSION = '0.001002';
  7. our @EXPORT_OK = qw/import_pubkeys process_message/;
  8. use Email::Sender::Simple qw/sendmail/;
  9. use File::Share qw/dist_file/;
  10. use File::Slurp qw/read_file/;
  11. use File::Spec::Functions qw/rel2abs/;
  12. use IO::Handle;
  13. use Getopt::Long;
  14. use MIME::Entity;
  15. use MIME::Parser;
  16. use Mail::GnuPG;
  17. use PerlX::Maybe;
  18. use Template;
  19. use Try::Tiny;
  20. sub debug { say STDERR @_ if $ENV{EDUARD_DEBUG} }
  21. sub stringify ($) { join '', map {; '>', $_ } @{$_[0]} }
  22. sub mg {
  23. Mail::GnuPG->new(
  24. key => $ENV{EDUARD_KEY},
  25. maybe always_trust => $ENV{EDUARD_ALWAYS_TRUST},
  26. maybe keydir => $ENV{EDUARD_KEYDIR},
  27. maybe passphrase => $ENV{EDUARD_PASSPHRASE},
  28. maybe use_agent => $ENV{EDUARD_USE_AGENT},
  29. @_);
  30. }
  31. sub mp {
  32. my $parser = MIME::Parser->new;
  33. $parser->decode_bodies($_[0] // 0);
  34. $parser->output_to_core(1);
  35. $parser
  36. }
  37. sub first_part{
  38. my ($ent) = @_;
  39. return first_part ($ent->parts(0)) if $ent->parts;
  40. stringify [$ent->bodyhandle->as_lines]
  41. }
  42. sub import_pubkeys {
  43. my ($ent, $mg) = @_;
  44. my @keys;
  45. if ($ent->mime_type eq 'application/pgp-keys') {
  46. $ent = mp(1)->parse_data($ent->stringify);
  47. my $gpg = GnuPG::Interface->new;
  48. $mg->_set_options($gpg);
  49. $gpg->options->quiet(1);
  50. my ($input, $status) = (IO::Handle->new, IO::Handle->new);
  51. my $pid = $gpg->import_keys(handles => GnuPG::Handles->new(stdin => $input, status => $status));
  52. my $read = Mail::GnuPG::_communicate([$status], [$input], {$input => $ent->bodyhandle->as_string});
  53. push @keys, map { /IMPORT_OK \d+ (\w+)/ } $read->{$status};
  54. waitpid $pid, 0
  55. }
  56. push @keys, import_pubkeys ($_, $mg) for $ent->parts;
  57. @keys
  58. }
  59. sub find_pgp_part {
  60. my ($ent, $mg) = @_;
  61. do {
  62. my $part = find_pgp_part ($_, $mg);
  63. return $part if $part
  64. } for $ent->parts;
  65. return $ent if $ent->bodyhandle && ($mg->is_signed($ent) || $mg->is_encrypted($ent));
  66. return
  67. }
  68. sub process_message {
  69. my ($in) = @_;
  70. my $msg;
  71. my $parser = mp;
  72. $msg = $in if ref $in eq 'MIME::Entity';
  73. $msg = $parser->parse ($in) if ref $in eq 'IO';
  74. $msg = $parser->parse_data ($in) if ref $in eq 'SCALAR';
  75. $msg = $parser->parse_open ($in) unless ref $in;
  76. die "Don't know how to parse $in" unless $msg;
  77. if ($msg->mime_type ne 'multipart/signed' && $msg->mime_type ne 'multipart/encrypted') {
  78. # PGP/Inline requires decoding
  79. $parser->decode_bodies(1);
  80. $msg = $parser->parse_data($msg->stringify)
  81. }
  82. my $gpg = mg;
  83. if ($msg->effective_type ne 'multipart/signed' && $msg->effective_type ne 'multipart/encrypted' && !$msg->bodyhandle) {
  84. debug 'This is (probably) a PGP/Inline mail with attachments. Working around...';
  85. $msg = find_pgp_part $msg, $gpg
  86. }
  87. if ($gpg->is_signed($msg)) {
  88. debug 'This mail looks signed';
  89. my ($code, $keyid, $email) = $gpg->verify($msg);
  90. return sign_error => (
  91. message => stringify $gpg->{last_message}) if $code;
  92. return sign => (
  93. keyid => $keyid,
  94. email => $email,
  95. message => stringify $gpg->{last_message});
  96. }
  97. if ($gpg->is_encrypted($msg)) {
  98. debug 'This mail looks encrypted';
  99. my ($code, $keyid, $email) = $gpg->decrypt($msg);
  100. return encrypt_error => (
  101. message => stringify $gpg->{last_message}) if $code;
  102. return encrypt => (
  103. plaintext => stringify $gpg->{plaintext},
  104. decrypted => $gpg->{decrypted},
  105. message => stringify $gpg->{last_message}) unless defined $keyid;
  106. return signencrypt => (
  107. keyid => $keyid,
  108. email => $email,
  109. plaintext => stringify $gpg->{plaintext},
  110. decrypted => $gpg->{decrypted},
  111. message => stringify $gpg->{last_message});
  112. }
  113. debug 'This mail doesn\'t seem to be signed or encrypted';
  114. return 'plain', message => ''
  115. }
  116. sub run {
  117. GetOptions(
  118. 'always-trust!' => \$ENV{EDUARD_ALWAYS_TRUST},
  119. 'debug!' => \$ENV{EDUARD_DEBUG},
  120. 'from=s' => \$ENV{EDUARD_FROM},
  121. 'key=s' => \$ENV{EDUARD_KEY},
  122. 'keydir=s' => \$ENV{EDUARD_KEYDIR},
  123. 'logfile=s' => \$ENV{EDUARD_LOGFILE},
  124. 'passphrase=s' => \$ENV{EDUARD_PASSPHRASE},
  125. 'tmpl-path=s' => \$ENV{EDUARD_TMPL_PATH},
  126. 'use-agent!' => \$ENV{EDUARD_USE_AGENT},
  127. );
  128. my $tmpl_path = $ENV{EDUARD_TMPL_PATH} // 'en';
  129. open STDERR, '>>', $ENV{EDUARD_LOGFILE} if $ENV{EDUARD_LOGFILE};
  130. my $in = mp->parse(\*STDIN);
  131. debug 'Received mail from ', $in->get('From');
  132. my @keys = import_pubkeys $in, mg;
  133. say 'Found keys: ', join ' ', @keys if @keys;
  134. my ($tmpl, %params);
  135. try {
  136. ($tmpl, %params) = process_message $in
  137. } catch {
  138. ($tmpl, %params) = (error => message => $_)
  139. };
  140. debug "Result is $tmpl, GnuPG said:\n", $params{message};
  141. $params{plaintext} = first_part $params{decrypted} if $params{decrypted};
  142. my $tt = Template->new(INCLUDE_PATH => rel2abs $tmpl_path, dist_file 'App-Eduard', 'tmpl');
  143. my ($keys, $result) = ('', '');
  144. $tt->process('keys', {keys => \@keys}, \$keys) if @keys;
  145. $tt->process($tmpl, \%params, \$result);
  146. my $email = MIME::Entity->build(
  147. From => $ENV{EDUARD_FROM},
  148. To => $in->get('From'),
  149. Type => 'text/plain; charset=UTF-8',
  150. Encoding=> '-SUGGEST',
  151. Subject => 'Re: ' . $in->get('Subject'),
  152. Data => $keys.$result);
  153. my $email_unencrypted = $email->dup;
  154. my $mg = mg always_trust => 1;
  155. my $encrypt_failed = $mg->mime_signencrypt($email, $in->get('From') =~ /<(.*)>/);
  156. debug 'Could not encrypt message, sending unencrypted. GnuPG said:', "\n", stringify $mg->{last_message} if $encrypt_failed;
  157. sendmail $encrypt_failed ? $email_unencrypted : $email
  158. }
  159. 1;
  160. __END__
  161. =encoding utf-8
  162. =head1 NAME
  163. App::Eduard - GnuPG email sign/encrypt testing bot
  164. =head1 SYNOPSIS
  165. use App::Eduard;
  166. my ($status, %params) = process_message '/path/to/message';
  167. if ($status eq 'signencrypt') {
  168. say 'This message is encrypted and signed with key ', $params{keyid}, ' from ', $params{email};
  169. say 'Its contents are: ', $params{plaintext};
  170. } elsif ($status eq 'encrypt') {
  171. say 'This message is encrypted but not signed';
  172. say 'Its contents are: ', $params{plaintext};
  173. } elsif ($status eq 'encrypt_error') {
  174. say 'This message is encrypted but I was unable to decrypt it. GnuPG output: ', $params{message};
  175. } elsif ($status eq 'sign') {
  176. say 'This message is signed with key ', $params{keyid}, ' from ', $params{email};
  177. } elsif ($status eq 'sign_error') {
  178. say 'This message is signed but I was unable to verify the signature. GnuPG output: ', $params{message};
  179. } elsif ($status eq 'plain') {
  180. say 'This message is neither signed nor encrypted';
  181. } elsif ($status eq 'error') {
  182. say 'There was an error processing the message: ', $params{message};
  183. }
  184. =head1 DESCRIPTION
  185. Eduard is Ceata's reimplementation of the Edward reply bot referenced in L<https://emailselfdefense.fsf.org/>.
  186. =head1 EXPORTS
  187. None by default.
  188. =head2 B<import_keys>(I<$entity>, I<$gpg>)
  189. Scan a message for PGP public keys, and import them. I<$entity> is a L<MIME::Entity> to scan, I<$gpg> is a L<Mail::GnuPG> instance.
  190. Returns a list of fingerprints of keys found.
  191. =head2 B<process_message>(I<$message>)
  192. Analyze a message, looking for PGP signatures and encryption. I<$message> can be:
  193. =over
  194. =item A filehandle reference, e.g. C<\*STDIN>.
  195. =item A reference to a scalar which holds the message contents.
  196. =item A scalar which represents a path to a message.
  197. =item A L<MIME::Entity> object created with decode_bodies(0)
  198. =back
  199. The function returns a status followed by a hash. Possible results:
  200. =over
  201. =item plain
  202. The message is neither signed nor encrypted.
  203. =item sign_error, message => $message
  204. The message is signed but the signature could not be verified. GnuPG output is $message.
  205. =item sign, keyid => $keyid, email => $email, message => $message
  206. The message is signed with key $keyid from $email. GnuPG output is $message.
  207. =item encrypt_error, message => $message
  208. The message is encrypted and unable to be decrypted. GnuPG output is $message.
  209. =item encrypt, plaintext => $plaintext, decrypted => $decrypted, message => $message
  210. The message is encrypted and unsigned. $plaintext is the decrypted message as plain text, while $decrypted is a MIME::Entity representing the decrypted message. GnuPG output is $message.
  211. =item signencrypt, plaintext => $plaintext, decrypted => $decrypted, keyid => $keyid, email => $email, message => $message
  212. The message is encrypted and signed with key $keyid from $email. $plaintext is the decrypted message as plain text, while $decrypted is a MIME::Entity representing the decrypted message. GnuPG output is $message.
  213. =item error, message => $message
  214. There was an error while processing the message. The error can be found in $message.
  215. =back
  216. =head1 ENVIRONMENT
  217. This module is configured via the %ENV hash. See the L<eduard(1)> manpage for more information.
  218. =head1 SEE ALSO
  219. L<eduard(1)>, L<http://ceata.org/proiecte/eduard>
  220. =head1 AUTHOR
  221. Marius Gavrilescu, E<lt>marius@ceata.orgE<gt>
  222. =head1 COPYRIGHT AND LICENSE
  223. Copyright (C) 2014 by Fundația Ceata
  224. This library is free software; you can redistribute it and/or modify
  225. it under the same terms as Perl itself, either Perl version 5.18.2 or,
  226. at your option, any later version of Perl 5 you may have available.
  227. =cut