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/>.
24 use parent qw(HTTP::Server::Simple::CGI);
33 use POSIX qw(strftime);
38 '/' => { handler => \&noop,
39 methods => { 'GET' => 1 },
41 '/reviewcert' => { handler => \&reviewcert,
42 methods => { 'POST' => 1 },
44 '/extracerts' => { handler => \&extracerts,
45 methods => { 'POST' => 1 },
49 # Net::Server log_level goes from 0 to 4
50 # this is scaled to match.
64 my $rsa_decoder = Convert::ASN1->new;
65 $rsa_decoder->prepare(q<
76 my $level = $loglevels{lc($ENV{MSVA_LOG_LEVEL})};
77 $level = $loglevels{error} if (! defined $level);
79 if ($loglevels{lc($msglevel)} <= $level) {
85 my $level = $loglevels{lc($ENV{MSVA_LOG_LEVEL})};
86 $level = $loglevels{error} if (! defined $level);
91 return 'Net::Server::MSVA';
98 if (exists $ENV{MSVA_PORT}) {
99 $port = $ENV{MSVA_PORT} + 0;
100 die sprintf("not a reasonable port %d", $port) if (($port >= 65536) || $port <= 0);
102 # start the server on requested port
103 my $self = $class->SUPER::new($port);
104 if (! exists $ENV{MSVA_PORT}) {
105 # we can't pass port 0 to the constructor because it evaluates
106 # to false, so HTTP::Server::Simple just uses its internal
107 # default of 8080. But if we want to select an arbitrary open
108 # port, we *can* set it here.
112 $self->{allowed_uids} = {};
113 if (exists $ENV{MSVA_ALLOWED_USERS}) {
114 msvalog('verbose', "MSVA_ALLOWED_USERS environment variable is set.\nLimiting access to specified users.\n");
115 foreach my $user (split(/ +/, $ENV{MSVA_ALLOWED_USERS})) {
116 my ($name, $passwd, $uid);
117 if ($user =~ /^[0-9]+$/) {
118 $uid = $user + 0; # force to integer
120 ($name,$passwd,$uid) = getpwnam($user);
123 msvalog('verbose', "Allowing access from user ID %d\n", $uid);
124 $self->{allowed_uids}->{$uid} = $user;
126 msvalog('error', "Could not find user '%d'; not allowing\n", $user);
130 # default is to allow access only to the current user
131 $self->{allowed_uids}->{POSIX::getuid()} = 'self';
134 bless ($self, $class);
141 return '200 OK', { available => JSON::true,
143 server => "MSVA-Perl ".$version };
146 # returns an empty list if bad key found.
147 sub parse_openssh_pubkey {
149 my ($label, $prop) = split(/ +/, $data);
150 $prop = decode_base64($prop) or return ();
152 msvalog('debug', "key properties: %s\n", unpack('H*', $prop));
154 while (length($prop) > 4) {
155 my $size = unpack('N', substr($prop, 0, 4));
156 msvalog('debug', "size: 0x%08x\n", $size);
157 return () if (length($prop) < $size + 4);
158 push(@out, substr($prop, 4, $size));
159 $prop = substr($prop, 4 + $size);
161 return () if ($label ne $out[0]);
165 # return the numeric ID of the peer on the other end of $socket,
166 # returning undef if unknown.
167 sub get_remote_peer_id {
170 my $sock = IO::Socket->new_from_fd($socket, 'r');
171 # check SO_PEERCRED -- if this was a TCP socket, Linux
172 # might not be able to support SO_PEERCRED (even on the loopback),
173 # though apparently some kernels (Solaris?) are able to.
176 my $socktype = $sock->sockopt(SO_TYPE) or die "could not get SO_TYPE info";
177 if (defined $socktype) {
178 msvalog('debug', "sockopt(SO_TYPE) = %d\n", $socktype);
180 msvalog('verbose', "sockopt(SO_TYPE) returned undefined.\n");
183 my $peercred = $sock->sockopt(SO_PEERCRED) or die "could not get SO_PEERCRED info";
184 my $remotepeer = $sock->peername();
185 my $family = sockaddr_family($remotepeer); # should be AF_UNIX (a.k.a. AF_LOCAL) or AF_INET
187 msvalog('verbose', "socket family: %d\nsocket type: %d\n", $family, $socktype);
190 # FIXME: on i386 linux, this appears to be three ints, according to
191 # /usr/include/linux/socket.h. What about other platforms?
192 my ($pid, $uid, $gid) = unpack('iii', $peercred);
194 msvalog('verbose', "SO_PEERCRED: pid: %u, uid: %u, gid: %u\n",
197 if ($pid != 0 && $uid != 0) { # then we can accept it:
198 $remotepeerid = $uid;
202 # another option in Linux would be to parse the contents of
203 # /proc/net/tcp to find the uid of the peer process based on that
205 if (! defined $remotepeerid) {
207 if ($family == AF_INET) {
209 } elsif ($family == AF_INET6) {
212 if (defined $proto) {
213 if ($socktype == &SOCK_STREAM) {
214 $proto = 'tcp'.$proto;
215 } elsif ($socktype == &SOCK_DGRAM) {
216 $proto = 'udp'.$proto;
220 if (defined $proto) {
221 my ($port, $iaddr) = unpack_sockaddr_in($remotepeer);
222 my $iaddrstring = unpack("H*", reverse($iaddr));
223 msvalog('verbose', "Port: %04x\nAddr: %s\n", $port, $iaddrstring);
224 my $remmatch = lc(sprintf("%s:%04x", $iaddrstring, $port));
225 my $infofile = '/proc/net/'.$proto;
226 my $f = new IO::File;
227 if ( $f->open('< '.$infofile)) {
228 my @header = split(/ +/, <$f>);
229 my ($localaddrix, $uidix);
232 while ($ix <= $#header) {
233 $localaddrix = $ix - $skipcount if (lc($header[$ix]) eq 'local_address');
234 $uidix = $ix - $skipcount if (lc($header[$ix]) eq 'uid');
235 $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
238 if (!defined $localaddrix) {
239 msvalog('info', "Could not find local_address field in %s; unable to determine peer UID\n",
241 } elsif (!defined $uidix) {
242 msvalog('info', "Could not find uid field in %s; unable to determine peer UID\n",
245 msvalog('debug', "local_address: %d; uid: %d\n", $localaddrix,$uidix);
246 while (my @line = split(/ +/,<$f>)) {
247 if (lc($line[$localaddrix]) eq $remmatch) {
248 if (defined $remotepeerid) {
249 msvalog('error', "Warning! found more than one remote uid! (%s and %s\n", $remotepeerid, $line[$uidix]);
251 $remotepeerid = $line[$uidix];
252 msvalog('info', "remote peer is uid %d\n",
257 msvalog('error', "Warning! could not find peer information in %s. Not verifying.\n", $infofile) unless defined $remotepeerid;
259 } else { # FIXME: we couldn't read the file. what should we
260 # do besides warning?
261 msvalog('info', "Could not read %s; unable to determine peer UID\n",
267 return $remotepeerid;
274 my $remotepeerid = get_remote_peer_id(select);
276 if (defined $remotepeerid) {
277 # test that this is an allowed user:
278 if (exists $self->{allowed_uids}->{$remotepeerid}) {
279 msvalog('verbose', "Allowing access from uid %d (%s)\n", $remotepeerid, $self->{allowed_uids}->{$remotepeerid});
281 msvalog('error', "MSVA client connection from uid %d, forbidden.\n", $remotepeerid);
282 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",
283 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())),);
288 my $path = $cgi->path_info();
289 my $handler = $dispatch{$path};
291 if (ref($handler) eq "HASH") {
292 if (! exists $handler->{methods}->{$cgi->request_method()}) {
293 printf("HTTP/1.0 405 Method not allowed\r\nAllow: %s\r\nDate: %s\r\n",
294 join(', ', keys(%{$handler->{methods}})),
295 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())));
296 } elsif (ref($handler->{handler}) ne "CODE") {
297 printf("HTTP/1.0 500 Server Error\r\nDate: %s\r\n",
298 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())));
301 my $ctype = $cgi->content_type();
302 msvalog('verbose', "Got %s %s (Content-Type: %s)\n", $cgi->request_method(), $path, defined $ctype ? $ctype : '**none supplied**');
303 if (defined $ctype) {
304 my @ctypes = split(/; */, $ctype);
305 $ctype = shift @ctypes;
306 if ($ctype eq 'application/json') {
307 $data = from_json($cgi->param('POSTDATA'));
311 my ($status, $object) = $handler->{handler}($data);
312 my $ret = to_json($object);
313 msvalog('info', "returning: %s\n", $ret);
314 printf("HTTP/1.0 %s\r\nDate: %s\r\nContent-Type: application/json\r\n\r\n%s",
316 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())),
320 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",
321 strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())),
322 $path, ' * '.join("\r\n * ", keys %dispatch) );
330 return if !ref $data;
332 my $uid = $data->{context}.'://'.$data->{peer};
334 my $rawdata = join('', map(chr, @{$data->{pkc}->{data}}));
335 my $cert = Crypt::X509->new(cert => $rawdata);
336 msvalog('verbose', "cert subject: %s\n", $cert->subject_cn());
337 msvalog('verbose', "cert issuer: %s\n", $cert->issuer_cn());
338 msvalog('verbose', "cert pubkey algo: %s\n", $cert->PubKeyAlg());
339 msvalog('verbose', "cert pubkey: %s\n", unpack('H*', $cert->pubkey()));
341 my $status = '200 OK';
342 my $ret = { valid => JSON::false,
343 message => 'Unknown failure',
345 if ($cert->PubKeyAlg() ne 'RSA') {
346 $ret->{message} = sprintf('public key was algo "%s" (OID %s). MSVA.pl only supports RSA',
347 $cert->PubKeyAlg(), $cert->pubkey_algorithm);
349 my $key = $rsa_decoder->decode($cert->pubkey());
351 # make sure that the returned integers are Math::BigInts:
352 $key->{exponent} = Math::BigInt->new($key->{exponent}) unless (ref($key->{exponent}));
353 $key->{modulus} = Math::BigInt->new($key->{modulus}) unless (ref($key->{modulus}));
354 msvalog('debug', "cert info:\nmodulus: %s\nexponent: %s\n",
355 $key->{modulus}->as_hex(),
356 $key->{exponent}->as_hex(),
359 if ($key->{modulus}->copy()->blog(2) < 1000) { # FIXME: this appears to be the full pubkey, including DER overhead
360 $ret->{message} = sprintf('public key size is less than 1000 bits (was: %d bits)', $cert->pubkey_size());
362 $ret->{message} = sprintf('Failed to validate "%s" through the OpenPGP Web of Trust.', $uid);
365 # clean up the path for taint-check mode:
366 $ENV{PATH} = '/usr/local/bin:/usr/bin:/bin';
368 # FIXME: should test exit code of open() and do something intelligent with it.
369 open($fh, '-|', 'monkeysphere', 'keys-for-userid', $uid);
371 my @keyinfo = parse_openssh_pubkey($_);
372 if (scalar(@keyinfo) != 3 || $keyinfo[0] ne "ssh-rsa") {
373 msvalog('info', "got unknown or non-RSA key from monkeysphere\n");
376 msvalog('verbose', "got good RSA key from monkeysphere: \nExponent: 0x%s\nModulus: 0x%s\n", unpack('H*', $keyinfo[1]), unpack('H*', $keyinfo[2]));
377 if ($key->{exponent}->bcmp(Math::BigInt->new('0x'.unpack('H*', $keyinfo[1]))) == 0 &&
378 $key->{modulus}->bcmp(Math::BigInt->new('0x'.unpack('H*', $keyinfo[2]))) == 0) {
379 msvalog('verbose', "...and it matches!\n");
380 $ret->{valid} = JSON::true;
381 $ret->{message} = sprintf('Successfully validated "%s" through the OpenPGP Web of Trust.', $uid);
384 my $closeval = close($fh);
385 my $subproc_retval = POSIX::WEXITSTATUS($?);
386 if ((!$closeval) && ($! != 0)) {
387 msvalog('error', "Got bad errno from closing monkeysphere subprocess: %d \n", $!);
389 if ($subproc_retval != 0) {
390 msvalog('error', "Got bad return code from monkeysphere subprocess: %d \n", $subproc_retval);
391 # FIXME: marginal UI here? do something with the return code?
396 msvalog('error', "failed to decode %s\n", unpack('H*', $cert->pubkey()));
397 $ret->{message} = sprintf('failed to decode the public key', $uid);
401 return $status, $ret;
409 msvalog('debug', "Subprocess %d terminated.\n", $pid);
411 if (exists $self->{child_pid} &&
412 ($self->{child_pid} == 0 ||
413 $self->{child_pid} == $pid)) {
414 my $exitstatus = POSIX::WEXITSTATUS($?);
415 msvalog('verbose', "Subprocess %d terminated; exiting %d.\n", $pid, $exitstatus);
416 $server->set_exit_status($exitstatus);
417 $server->server_close();
421 # use sparingly! We want to keep taint mode around for the data we
422 # get over the network. this is only here because we want to treat
423 # the command line arguments differently for the subprocess.
434 my $socketcount = @{ $server->{server}->{sock} };
435 if ( $socketcount != 1 ) {
436 msvalog('error', "%d sockets open; should have been 1.", $socketcount);
437 $server->set_exit_status(10);
438 $server->server_close();
440 my $port = @{ $server->{server}->{sock} }[0]->sockport();
441 if ((! defined $port) || ($port < 1) || ($port >= 65536)) {
442 msvalog('error', "got nonsense port: %d.", $port);
443 $server->set_exit_status(11);
444 $server->server_close();
446 if ((exists $ENV{MSVA_PORT}) && (($ENV{MSVA_PORT} + 0) != $port)) {
447 msvalog('error', "Explicitly requested port %d, but got port: %d.", ($ENV{MSVA_PORT}+0), $port);
448 $server->set_exit_status(13);
449 $server->server_close();
453 my $argcount = @ARGV;
455 $self->{child_pid} = 0; # indicate that we are planning to fork.
457 if (! defined $fork) {
458 msvalog('error', "could not fork\n");
461 msvalog('debug', "Child process has PID %d\n", $fork);
462 $self->{child_pid} = $fork;
464 msvalog('verbose', "PID %d executing: \n", $$);
465 for my $arg (@ARGV) {
466 msvalog('verbose', " %s\n", $arg);
468 $ENV{PATH} = untaint($ENV{PATH});
471 push @args, untaint($_);
473 $ENV{MONKEYSPHERE_VALIDATION_AGENT_SOCKET} = sprintf('http://localhost:%d', $self->port);
474 exec(@args) or exit 111;
478 printf("MONKEYSPHERE_VALIDATION_AGENT_SOCKET=http://localhost:%d;\nexport MONKEYSPHERE_VALIDATION_AGENT_SOCKET;\n", $self->port);
479 # FIXME: consider daemonizing here to behave more like
480 # ssh-agent. maybe avoid backgrounding by setting
481 # MSVA_NO_BACKGROUND.
488 return '500 not yet implemented', { };
494 my $server = MSVA->new();
495 $server->run(host=>'localhost',
496 log_level=>MSVA::get_log_level(),
497 user => POSIX::geteuid(), # explicitly choose regular user and group (avoids spew)
498 group => POSIX::getegid(),
504 msva-perl - Perl implementation of a Monkeysphere Validation Agent
508 msva-perl [ COMMAND [ ARGS ... ] ]
512 msva-perl provides a Perl implementation of the Monkeysphere
513 Validation Agent, a certificate validation service.
517 The Monkeysphere Validation Agent offers a local service for tools to
518 validate certificates (both X.509 and OpenPGP) and other public keys.
520 Clients of the validation agent query it with a public key carrier (a
521 raw public key, or some flavor of certificate), the supposed name of
522 the remote peer offering the pubkey, and the context in which the
523 validation check is relevant (e.g. ssh, https, etc).
525 The validation agent then tells the client whether it was able to
526 successfully validate the peer's use of the public key in the given
529 msva-perl relies on monkeysphere(1), which uses the user's OpenPGP web
530 of trust to validate the peer's use of public keys.
534 Launched with no arguments, msva-perl simply runs and listens forever.
536 Launched with arguments, it sets up a listener, spawns a subprocess
537 using the supplied command and arguments, but with the
538 MONKEYSPHERE_VALIDATION_AGENT_SOCKET environment variable set to refer
539 to its listener. When the subprocess terminates, msva-perl tears down
540 the listener and exits as well, returning the same value as the
543 This is a similar invocation pattern to that of ssh-agent(1).
545 =head1 ENVIRONMENT VARIABLES
547 msva-perl is configured by means of environment variables.
553 msva-perl logs messages about its operation to stderr. MSVA_LOG_LEVEL
554 controls its verbosity, and should be one of (in increasing
555 verbosity): silent, quiet, fatal, error, info, verbose, debug, debug1,
556 debug2, debug3. Default is 'error'.
558 =item MSVA_ALLOWED_USERS
560 If your system is capable of it, msva-perl tries to figure out the
561 owner of the connecting client. If MSVA_ALLOWED_USERS is unset,
562 msva-perl will only permit connections from the user msva is running
563 as. If you set MSVA_ALLOWED_USERS, msva-perl will treat it as a list
564 of local users (by name or user ID) who are allowed to connect.
568 msva-perl listens on a local TCP socket to facilitate access. You can
569 choose what port to bind to by setting MSVA_PORT. Default is to bind
570 on an arbitrary open port.
574 =head1 COMMUNICATION PROTOCOL DETAILS
576 Communications with the Monkeysphere Validation Agent are in the form
577 of JSON requests over plain HTTP. Responses from the agent are also
578 JSON objects. For details on the structure of the requests and
579 responses, please see
580 http://web.monkeysphere.info/validation-agent/protocol
582 =head1 SECURITY CONSIDERATIONS
584 msva-perl deliberately binds to the loopback adapter (via named lookup
585 of "localhost") so that remote users do not get access to the daemon.
586 On systems (like Linux) which report ownership of TCP sockets in
587 /proc/net/tcp, msva-perl will refuse access from random users (see
588 MSVA_ALLOWED_USERS above).
592 monkeysphere(1), monkeysphere(7), ssh-agent(1)
594 =head1 BUGS AND FEEDBACK
596 Bugs or feature requests for msva-perl should be filed with the
597 Monkeysphere project's bug tracker at
598 https://labs.riseup.net/code/projects/monkeysphere/issues/
600 =head1 AUTHORS AND CONTRIBUTORS
602 Daniel Kahn Gillmor E<lt>dkg@fifthhorseman.net<gt>
604 The Monkeysphere Team http://web.monkeysphere.info/
606 =head1 COPYRIGHT AND LICENSE
608 Copyright © Daniel Kahn Gillmor and others from the Monkeysphere team.
609 msva-perl is free software, distributed under the GNU Public License,