3 # Monkeysphere Validation Agent, Perl version
4 # Copyright © 2010 Daniel Kahn Gillmor <dkg@fifthhorseman.net>
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
26 use parent qw(HTTP::Server::Simple::CGI);
28 use Regexp::Common qw /net/;
36 use POSIX qw(strftime);
37 # we need the version of GnuPG::Interface that knows about pubkey_data, etc:
38 use GnuPG::Interface 0.42.02;
42 my $gnupg = GnuPG::Interface->new();
45 '/' => { handler => \&noop,
46 methods => { 'GET' => 1 },
48 '/reviewcert' => { handler => \&reviewcert,
49 methods => { 'POST' => 1 },
51 '/extracerts' => { handler => \&extracerts,
52 methods => { 'POST' => 1 },
56 # Net::Server log_level goes from 0 to 4
57 # this is scaled to match.
71 my $rsa_decoder = Convert::ASN1->new;
72 $rsa_decoder->prepare(q<
83 my $level = $loglevels{lc($ENV{MSVA_LOG_LEVEL})};
84 $level = $loglevels{error} if (! defined $level);
86 if ($loglevels{lc($msglevel)} <= $level) {
92 my $level = $loglevels{lc($ENV{MSVA_LOG_LEVEL})};
93 $level = $loglevels{error} if (! defined $level);
98 return 'Net::Server::MSVA';
105 if (exists $ENV{MSVA_PORT}) {
106 $port = $ENV{MSVA_PORT} + 0;
107 die sprintf("not a reasonable port %d", $port) if (($port >= 65536) || $port <= 0);
109 # start the server on requested port
110 my $self = $class->SUPER::new($port);
111 if (! exists $ENV{MSVA_PORT}) {
112 # we can't pass port 0 to the constructor because it evaluates
113 # to false, so HTTP::Server::Simple just uses its internal
114 # default of 8080. But if we want to select an arbitrary open
115 # port, we *can* set it here.
119 $self->{allowed_uids} = {};
120 if (exists $ENV{MSVA_ALLOWED_USERS}) {
121 msvalog('verbose', "MSVA_ALLOWED_USERS environment variable is set.\nLimiting access to specified users.\n");
122 foreach my $user (split(/ +/, $ENV{MSVA_ALLOWED_USERS})) {
123 my ($name, $passwd, $uid);
124 if ($user =~ /^[0-9]+$/) {
125 $uid = $user + 0; # force to integer
127 ($name,$passwd,$uid) = getpwnam($user);
130 msvalog('verbose', "Allowing access from user ID %d\n", $uid);
131 $self->{allowed_uids}->{$uid} = $user;
133 msvalog('error', "Could not find user '%d'; not allowing\n", $user);
137 # default is to allow access only to the current user
138 $self->{allowed_uids}->{POSIX::getuid()} = 'self';
141 bless ($self, $class);
148 return '200 OK', { available => JSON::true,
150 server => "MSVA-Perl ".$version };
153 # returns an empty list if bad key found.
154 sub parse_openssh_pubkey {
156 my ($label, $prop) = split(/ +/, $data);
157 $prop = decode_base64($prop) or return ();
159 msvalog('debug', "key properties: %s\n", unpack('H*', $prop));
161 while (length($prop) > 4) {
162 my $size = unpack('N', substr($prop, 0, 4));
163 msvalog('debug', "size: 0x%08x\n", $size);
164 return () if (length($prop) < $size + 4);
165 push(@out, substr($prop, 4, $size));
166 $prop = substr($prop, 4 + $size);
168 return () if ($label ne $out[0]);
172 # return the numeric ID of the peer on the other end of $socket,
173 # returning undef if unknown.
174 sub get_remote_peer_id {
177 my $sock = IO::Socket->new_from_fd($socket, 'r');
178 # check SO_PEERCRED -- if this was a TCP socket, Linux
179 # might not be able to support SO_PEERCRED (even on the loopback),
180 # though apparently some kernels (Solaris?) are able to.
183 my $socktype = $sock->sockopt(SO_TYPE) or die "could not get SO_TYPE info";
184 if (defined $socktype) {
185 msvalog('debug', "sockopt(SO_TYPE) = %d\n", $socktype);
187 msvalog('verbose', "sockopt(SO_TYPE) returned undefined.\n");
190 my $peercred = $sock->sockopt(SO_PEERCRED) or die "could not get SO_PEERCRED info";
191 my $remotepeer = $sock->peername();
192 my $family = sockaddr_family($remotepeer); # should be AF_UNIX (a.k.a. AF_LOCAL) or AF_INET
194 msvalog('verbose', "socket family: %d\nsocket type: %d\n", $family, $socktype);
197 # FIXME: on i386 linux, this appears to be three ints, according to
198 # /usr/include/linux/socket.h. What about other platforms?
199 my ($pid, $uid, $gid) = unpack('iii', $peercred);
201 msvalog('verbose', "SO_PEERCRED: pid: %u, uid: %u, gid: %u\n",
204 if ($pid != 0 && $uid != 0) { # then we can accept it:
205 $remotepeerid = $uid;
209 # another option in Linux would be to parse the contents of
210 # /proc/net/tcp to find the uid of the peer process based on that
212 if (! defined $remotepeerid) {
214 if ($family == AF_INET) {
216 } elsif ($family == AF_INET6) {
219 if (defined $proto) {
220 if ($socktype == &SOCK_STREAM) {
221 $proto = 'tcp'.$proto;
222 } elsif ($socktype == &SOCK_DGRAM) {
223 $proto = 'udp'.$proto;
227 if (defined $proto) {
228 my ($port, $iaddr) = unpack_sockaddr_in($remotepeer);
229 my $iaddrstring = unpack("H*", reverse($iaddr));
230 msvalog('verbose', "Port: %04x\nAddr: %s\n", $port, $iaddrstring);
231 my $remmatch = lc(sprintf("%s:%04x", $iaddrstring, $port));
232 my $infofile = '/proc/net/'.$proto;
233 my $f = new IO::File;
234 if ( $f->open('< '.$infofile)) {
235 my @header = split(/ +/, <$f>);
236 my ($localaddrix, $uidix);
239 while ($ix <= $#header) {
240 $localaddrix = $ix - $skipcount if (lc($header[$ix]) eq 'local_address');
241 $uidix = $ix - $skipcount if (lc($header[$ix]) eq 'uid');
242 $skipcount++ if (lc($header[$ix]) eq 'tx_queue') or (lc($header[$ix]) eq 'tr'); # these headers don't actually result in a new column during the data rows
245 if (!defined $localaddrix) {
246 msvalog('info', "Could not find local_address field in %s; unable to determine peer UID\n",
248 } elsif (!defined $uidix) {
249 msvalog('info', "Could not find uid field in %s; unable to determine peer UID\n",
252 msvalog('debug', "local_address: %d; uid: %d\n", $localaddrix,$uidix);
253 while (my @line = split(/ +/,<$f>)) {
254 if (lc($line[$localaddrix]) eq $remmatch) {
255 if (defined $remotepeerid) {
256 msvalog('error', "Warning! found more than one remote uid! (%s and %s\n", $remotepeerid, $line[$uidix]);
258 $remotepeerid = $line[$uidix];
259 msvalog('info', "remote peer is uid %d\n",
264 msvalog('error', "Warning! could not find peer information in %s. Not verifying.\n", $infofile) unless defined $remotepeerid;
266 } else { # FIXME: we couldn't read the file. what should we
267 # do besides warning?
268 msvalog('info', "Could not read %s; unable to determine peer UID\n",
274 return $remotepeerid;
281 my $remotepeerid = get_remote_peer_id(select);
283 if (defined $remotepeerid) {
284 # test that this is an allowed user:
285 if (exists $self->{allowed_uids}->{$remotepeerid}) {
286 msvalog('verbose', "Allowing access from uid %d (%s)\n", $remotepeerid, $self->{allowed_uids}->{$remotepeerid});
288 msvalog('error', "MSVA client connection from uid %d, forbidden.\n", $remotepeerid);
289 printf("HTTP/1.0 403 Forbidden -- peer does not match local user ID\r\nContent-Type: text/plain\r\nDate: %s\r\n\r\nHTTP/1.1 403 Not Found -- peer does not match the local user ID. Are you sure the agent is running as the same user?\r\n",
290 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())),);
295 my $path = $cgi->path_info();
296 my $handler = $dispatch{$path};
298 if (ref($handler) eq "HASH") {
299 if (! exists $handler->{methods}->{$cgi->request_method()}) {
300 printf("HTTP/1.0 405 Method not allowed\r\nAllow: %s\r\nDate: %s\r\n",
301 join(', ', keys(%{$handler->{methods}})),
302 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())));
303 } elsif (ref($handler->{handler}) ne "CODE") {
304 printf("HTTP/1.0 500 Server Error\r\nDate: %s\r\n",
305 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())));
308 my $ctype = $cgi->content_type();
309 msvalog('verbose', "Got %s %s (Content-Type: %s)\n", $cgi->request_method(), $path, defined $ctype ? $ctype : '**none supplied**');
310 if (defined $ctype) {
311 my @ctypes = split(/; */, $ctype);
312 $ctype = shift @ctypes;
313 if ($ctype eq 'application/json') {
314 $data = from_json($cgi->param('POSTDATA'));
318 my ($status, $object) = $handler->{handler}($data);
319 my $ret = to_json($object);
320 msvalog('info', "returning: %s\n", $ret);
321 printf("HTTP/1.0 %s\r\nDate: %s\r\nContent-Type: application/json\r\n\r\n%s",
323 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())),
327 printf("HTTP/1.0 404 Not Found -- not handled by Monkeysphere validation agent\r\nContent-Type: text/plain\r\nDate: %s\r\n\r\nHTTP/1.0 404 Not Found -- the path:\r\n %s\r\nis not handled by the MonkeySphere validation agent.\r\nPlease try one of the following paths instead:\r\n\r\n%s\r\n",
328 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())),
329 $path, ' * '.join("\r\n * ", keys %dispatch) );
337 if ($gpgkey->algo_num != 1) {
338 msvalog('verbose', "Monkeysphere only does RSA keys. This key is algorithm #%d\n", $gpgkey->algo_num);
340 if ($rsakey->{exponent}->bcmp($gpgkey->pubkey_data->[1]) == 0 &&
341 $rsakey->{modulus}->bcmp($gpgkey->pubkey_data->[0]) == 0) {
350 if ($data->{context} =~ /^(https|ssh)$/) {
351 $data->{context} = $1;
352 if ($data->{peer} =~ /^($RE{net}{domain})$/) {
354 return $data->{context}.'://'.$data->{peer};
361 return if !ref $data;
363 my $status = '200 OK';
364 my $ret = { valid => JSON::false,
365 message => 'Unknown failure',
368 my $uid = getuid($data);
370 msvalog('error', "invalid peer/context: %s/%s\n", $data->{context}, $data->{peer});
371 $ret->{message} = sprintf('invalid peer/context');
372 return $status, $ret;
375 my $rawdata = join('', map(chr, @{$data->{pkc}->{data}}));
376 my $cert = Crypt::X509->new(cert => $rawdata);
377 msvalog('verbose', "cert subject: %s\n", $cert->subject_cn());
378 msvalog('verbose', "cert issuer: %s\n", $cert->issuer_cn());
379 msvalog('verbose', "cert pubkey algo: %s\n", $cert->PubKeyAlg());
380 msvalog('verbose', "cert pubkey: %s\n", unpack('H*', $cert->pubkey()));
382 if ($cert->PubKeyAlg() ne 'RSA') {
383 $ret->{message} = sprintf('public key was algo "%s" (OID %s). MSVA.pl only supports RSA',
384 $cert->PubKeyAlg(), $cert->pubkey_algorithm);
386 my $key = $rsa_decoder->decode($cert->pubkey());
388 # make sure that the returned integers are Math::BigInts:
389 $key->{exponent} = Math::BigInt->new($key->{exponent}) unless (ref($key->{exponent}));
390 $key->{modulus} = Math::BigInt->new($key->{modulus}) unless (ref($key->{modulus}));
391 msvalog('debug', "cert info:\nmodulus: %s\nexponent: %s\n",
392 $key->{modulus}->as_hex(),
393 $key->{exponent}->as_hex(),
396 if ($key->{modulus}->copy()->blog(2) < 1000) { # FIXME: this appears to be the full pubkey, including DER overhead
397 $ret->{message} = sprintf('public key size is less than 1000 bits (was: %d bits)', $cert->pubkey_size());
399 $ret->{message} = sprintf('Failed to validate "%s" through the OpenPGP Web of Trust.', $uid);
401 # needed because $gnupg spawns child processes
402 $ENV{PATH} = '/usr/local/bin:/usr/bin:/bin';
403 # FIXME: check keyservers?
404 foreach my $gpgkey ($gnupg->get_public_keys('='.$uid)) {
407 if ($gpgkey->usage_flags =~ /A/) {
408 # we're only interested in keys that might have a valid
409 # authentication key/subkey:
410 foreach my $tryuid ($gpgkey->user_ids) {
411 if ($tryuid->as_string eq $uid) {
413 if ($tryuid->validity eq 'f' ||
414 $tryuid->validity eq 'u');
416 if ($tryuid->validity eq 'm');
419 if ($marginal and $notvalid) {
421 } elsif ($notvalid) {
422 msvalog('verbose', "got a key that was not fully-valid for UID %s\n", $uid);
423 msvalog('debug', Dumper($gpgkey));
425 if ($gpgkey->usage_flags =~ /a/) {
426 msvalog('verbose', "primary key 0x%s is authentication-capable\n", $gpgkey->hex_id);
427 if (keycomp($key, $gpgkey)) {
428 msvalog('verbose', "...and it matches!\n");
429 $ret->{valid} = JSON::true;
430 $ret->{message} = sprintf('Successfully validated "%s" through the OpenPGP Web of Trust.', $uid);
433 foreach my $subkey ($gpgkey->subkeys) {
434 msvalog('verbose', "subkey 0x%s is authentication-capable\n", $subkey->hex_id);
435 if (keycomp($key, $subkey)) {
436 msvalog('verbose', "...and it matches!\n");
437 $ret->{valid} = JSON::true;
438 $ret->{message} = sprintf('Successfully validated "%s" through the OpenPGP Web of Trust.', $uid);
447 msvalog('error', "failed to decode %s\n", unpack('H*', $cert->pubkey()));
448 $ret->{message} = sprintf('failed to decode the public key', $uid);
452 return $status, $ret;
460 msvalog('debug', "Subprocess %d terminated.\n", $pid);
462 if (exists $self->{child_pid} &&
463 ($self->{child_pid} == 0 ||
464 $self->{child_pid} == $pid)) {
465 my $exitstatus = POSIX::WEXITSTATUS($?);
466 msvalog('verbose', "Subprocess %d terminated; exiting %d.\n", $pid, $exitstatus);
467 $server->set_exit_status($exitstatus);
468 $server->server_close();
472 # use sparingly! We want to keep taint mode around for the data we
473 # get over the network. this is only here because we want to treat
474 # the command line arguments differently for the subprocess.
485 my $socketcount = @{ $server->{server}->{sock} };
486 if ( $socketcount != 1 ) {
487 msvalog('error', "%d sockets open; should have been 1.", $socketcount);
488 $server->set_exit_status(10);
489 $server->server_close();
491 my $port = @{ $server->{server}->{sock} }[0]->sockport();
492 if ((! defined $port) || ($port < 1) || ($port >= 65536)) {
493 msvalog('error', "got nonsense port: %d.", $port);
494 $server->set_exit_status(11);
495 $server->server_close();
497 if ((exists $ENV{MSVA_PORT}) && (($ENV{MSVA_PORT} + 0) != $port)) {
498 msvalog('error', "Explicitly requested port %d, but got port: %d.", ($ENV{MSVA_PORT}+0), $port);
499 $server->set_exit_status(13);
500 $server->server_close();
504 my $argcount = @ARGV;
506 $self->{child_pid} = 0; # indicate that we are planning to fork.
508 if (! defined $fork) {
509 msvalog('error', "could not fork\n");
512 msvalog('debug', "Child process has PID %d\n", $fork);
513 $self->{child_pid} = $fork;
515 msvalog('verbose', "PID %d executing: \n", $$);
516 for my $arg (@ARGV) {
517 msvalog('verbose', " %s\n", $arg);
519 $ENV{PATH} = untaint($ENV{PATH});
522 push @args, untaint($_);
524 # restore default SIGCHLD handling:
525 $SIG{CHLD} = 'DEFAULT';
526 $ENV{MONKEYSPHERE_VALIDATION_AGENT_SOCKET} = sprintf('http://localhost:%d', $self->port);
527 exec(@args) or exit 111;
531 printf("MONKEYSPHERE_VALIDATION_AGENT_SOCKET=http://localhost:%d;\nexport MONKEYSPHERE_VALIDATION_AGENT_SOCKET;\n", $self->port);
532 # FIXME: consider daemonizing here to behave more like
533 # ssh-agent. maybe avoid backgrounding by setting
534 # MSVA_NO_BACKGROUND.
541 return '500 not yet implemented', { };
547 my $server = MSVA->new();
548 $server->run(host=>'localhost',
549 log_level=>MSVA::get_log_level(),
550 user => POSIX::geteuid(), # explicitly choose regular user and group (avoids spew)
551 group => POSIX::getegid(),
557 msva-perl - Perl implementation of a Monkeysphere Validation Agent
561 msva-perl [ COMMAND [ ARGS ... ] ]
565 msva-perl provides a Perl implementation of the Monkeysphere
566 Validation Agent, a certificate validation service.
570 The Monkeysphere Validation Agent offers a local service for tools to
571 validate certificates (both X.509 and OpenPGP) and other public keys.
573 Clients of the validation agent query it with a public key carrier (a
574 raw public key, or some flavor of certificate), the supposed name of
575 the remote peer offering the pubkey, and the context in which the
576 validation check is relevant (e.g. ssh, https, etc).
578 The validation agent then tells the client whether it was able to
579 successfully validate the peer's use of the public key in the given
582 msva-perl relies on monkeysphere(1), which uses the user's OpenPGP web
583 of trust to validate the peer's use of public keys.
587 Launched with no arguments, msva-perl simply runs and listens forever.
589 Launched with arguments, it sets up a listener, spawns a subprocess
590 using the supplied command and arguments, but with the
591 MONKEYSPHERE_VALIDATION_AGENT_SOCKET environment variable set to refer
592 to its listener. When the subprocess terminates, msva-perl tears down
593 the listener and exits as well, returning the same value as the
596 This is a similar invocation pattern to that of ssh-agent(1).
598 =head1 ENVIRONMENT VARIABLES
600 msva-perl is configured by means of environment variables.
606 msva-perl logs messages about its operation to stderr. MSVA_LOG_LEVEL
607 controls its verbosity, and should be one of (in increasing
608 verbosity): silent, quiet, fatal, error, info, verbose, debug, debug1,
609 debug2, debug3. Default is 'error'.
611 =item MSVA_ALLOWED_USERS
613 If your system is capable of it, msva-perl tries to figure out the
614 owner of the connecting client. If MSVA_ALLOWED_USERS is unset,
615 msva-perl will only permit connections from the user msva is running
616 as. If you set MSVA_ALLOWED_USERS, msva-perl will treat it as a list
617 of local users (by name or user ID) who are allowed to connect.
621 msva-perl listens on a local TCP socket to facilitate access. You can
622 choose what port to bind to by setting MSVA_PORT. Default is to bind
623 on an arbitrary open port.
627 =head1 COMMUNICATION PROTOCOL DETAILS
629 Communications with the Monkeysphere Validation Agent are in the form
630 of JSON requests over plain HTTP. Responses from the agent are also
631 JSON objects. For details on the structure of the requests and
632 responses, please see
633 http://web.monkeysphere.info/validation-agent/protocol
635 =head1 SECURITY CONSIDERATIONS
637 msva-perl deliberately binds to the loopback adapter (via named lookup
638 of "localhost") so that remote users do not get access to the daemon.
639 On systems (like Linux) which report ownership of TCP sockets in
640 /proc/net/tcp, msva-perl will refuse access from random users (see
641 MSVA_ALLOWED_USERS above).
645 monkeysphere(1), monkeysphere(7), ssh-agent(1)
647 =head1 BUGS AND FEEDBACK
649 Bugs or feature requests for msva-perl should be filed with the
650 Monkeysphere project's bug tracker at
651 https://labs.riseup.net/code/projects/monkeysphere/issues/
653 =head1 AUTHORS AND CONTRIBUTORS
655 Daniel Kahn Gillmor E<lt>dkg@fifthhorseman.net<gt>
657 The Monkeysphere Team http://web.monkeysphere.info/
659 =head1 COPYRIGHT AND LICENSE
661 Copyright © Daniel Kahn Gillmor and others from the Monkeysphere team.
662 msva-perl is free software, distributed under the GNU Public License,