8d7d639aaa4737ba18226c569691352f1122dd98
[monkeysphere-validation-agent.git] / msva-perl
1 #!/usr/bin/perl -wT
2
3 # Monkeysphere Validation Agent, Perl version
4 # Copyright © 2010 Daniel Kahn Gillmor <dkg@fifthhorseman.net>
5 #
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.
10 #
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.
15 #
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/>.
18
19 use warnings;
20 use strict;
21
22 { package MSVA;
23
24   use parent qw(HTTP::Server::Simple::CGI);
25   require Crypt::X509;
26   use Convert::ASN1;
27   use MIME::Base64;
28   use IO::Socket;
29   use IO::File;
30   use Socket;
31
32   use JSON;
33   use POSIX qw(strftime);
34
35   my $version = '0.1';
36
37   my %dispatch = (
38                   '/' => { handler => \&noop,
39                            methods => { 'GET' => 1 },
40                          },
41                   '/reviewcert' => { handler => \&reviewcert,
42                                      methods => { 'POST' => 1 },
43                                    },
44                   '/extracerts' => { handler => \&extracerts,
45                                      methods => { 'POST' => 1 },
46                                    },
47                  );
48
49 # Net::Server log_level goes from 0 to 4
50 # this is scaled to match.
51   my %loglevels = (
52                    'silent' => 0,
53                    'quiet' => 0.25,
54                    'fatal' => 0.5,
55                    'error' => 1,
56                    'info' => 2,
57                    'verbose' => 3,
58                    'debug' => 4,
59                    'debug1' => 4,
60                    'debug2' => 5,
61                    'debug3' => 6,
62                   );
63
64   my $rsa_decoder = Convert::ASN1->new;
65   $rsa_decoder->prepare(q<
66
67    SEQUENCE {
68         modulus INTEGER,
69         exponent INTEGER
70    }
71           >);
72
73   sub msvalog {
74     my $msglevel = shift;
75
76     my $level = $loglevels{lc($ENV{MSVA_LOG_LEVEL})};
77     $level = $loglevels{error} if (! defined $level);
78
79     if ($loglevels{lc($msglevel)} <= $level) {
80       printf STDERR @_;
81     }
82   };
83
84   sub get_log_level {
85     my $level = $loglevels{lc($ENV{MSVA_LOG_LEVEL})};
86     $level = $loglevels{error} if (! defined $level);
87     return $level;
88   }
89
90   sub net_server {
91     return 'Net::Server::MSVA';
92   };
93
94   sub new {
95     my $class = shift;
96
97     my $port = 0;
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);
101     }
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.
109       $self->port(0);
110     }
111
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
119         } else {
120           ($name,$passwd,$uid) = getpwnam($user);
121         }
122         if (defined $uid) {
123           msvalog('verbose', "Allowing access from user ID %d\n", $uid);
124           $self->{allowed_uids}->{$uid} = $user;
125         } else {
126           msvalog('error', "Could not find user '%d'; not allowing\n", $user);
127         }
128       }
129     } else {
130       # default is to allow access only to the current user
131       $self->{allowed_uids}->{POSIX::getuid()} = 'self';
132     }
133
134     bless ($self, $class);
135     return $self;
136   }
137
138   sub noop {
139     my $self = shift;
140     my $cgi = shift;
141     return '200 OK', { available => JSON::true,
142                        protoversion => 1,
143                        server => "MSVA-Perl ".$version };
144   }
145
146   # returns an empty list if bad key found.
147   sub parse_openssh_pubkey {
148     my $data = shift;
149     my ($label, $prop) = split(/ +/, $data);
150     $prop = decode_base64($prop) or return ();
151
152     msvalog('debug', "key properties: %s\n", unpack('H*', $prop));
153     my @out;
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);
160     }
161     return () if ($label ne $out[0]);
162     return @out;
163   }
164
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 {
168     my $socket = shift;
169
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.
174
175     my $remotepeerid;
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);
179     } else {
180       msvalog('verbose', "sockopt(SO_TYPE) returned undefined.\n");
181     }
182
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
186
187     msvalog('verbose', "socket family: %d\nsocket type: %d\n", $family, $socktype);
188
189     if ($peercred) {
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);
193
194       msvalog('verbose', "SO_PEERCRED: pid: %u, uid: %u, gid: %u\n",
195               $pid, $uid, $gid,
196              );
197       if ($pid != 0 && $uid != 0) { # then we can accept it:
198         $remotepeerid = $uid;
199       }
200     }
201
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
204     # information.
205     if (! defined $remotepeerid) {
206       my $proto;
207       if ($family == AF_INET) {
208         $proto = '';
209       } elsif ($family == AF_INET6) {
210         $proto = '6';
211       }
212       if (defined $proto) {
213         if ($socktype == &SOCK_STREAM) {
214           $proto = 'tcp'.$proto;
215         } elsif ($socktype == &SOCK_DGRAM) {
216           $proto = 'udp'.$proto;
217         } else {
218           undef $proto;
219         }
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);
230             my $ix = 0;
231             my $skipcount = 0;
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
236               $ix++;
237             }
238             if (!defined $localaddrix) {
239               msvalog('info', "Could not find local_address field in %s; unable to determine peer UID\n",
240                       $infofile);
241             } elsif (!defined $uidix) {
242               msvalog('info', "Could not find uid field in %s; unable to determine peer UID\n",
243                       $infofile);
244             } else {
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]);
250                   } else {
251                     $remotepeerid = $line[$uidix];
252                     msvalog('info', "remote peer is uid %d\n",
253                             $remotepeerid);
254                   }
255                 }
256               }
257             msvalog('error', "Warning! could not find peer information in %s.  Not verifying.\n", $infofile) unless defined $remotepeerid;
258             }
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",
262                     $infofile);
263           }
264         }
265       }
266     }
267     return $remotepeerid;
268   }
269
270   sub handle_request {
271     my $self = shift;
272     my $cgi  = shift;
273
274     my $remotepeerid =  get_remote_peer_id(select);
275
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});
280       } else {
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())),);
284         return;
285       }
286     }
287
288     my $path = $cgi->path_info();
289     my $handler = $dispatch{$path};
290
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())));
299       } else {
300         my $data = {};
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'));
308           }
309         };
310
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",
315                $status,
316                strftime("%a, %d %b %Y %H:%M:%S %z", localtime(time())),
317                $ret);
318       }
319     } else {
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) );
323     }
324   }
325
326
327
328   sub reviewcert {
329     my $data  = shift;
330     return if !ref $data;
331
332     my $uid = $data->{context}.'://'.$data->{peer};
333
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()));
340
341     my $status = '200 OK';
342     my $ret =  { valid => JSON::false,
343                  message => 'Unknown failure',
344                };
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);
348     } else {
349       my $key = $rsa_decoder->decode($cert->pubkey());
350       if ($key) {
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(),
357                );
358
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());
361         } else {
362           $ret->{message} = sprintf('Failed to validate "%s" through the OpenPGP Web of Trust.', $uid);
363
364           my $fh;
365           # clean up the path for taint-check mode:
366           $ENV{PATH} = '/usr/local/bin:/usr/bin:/bin';
367
368           # FIXME: should test exit code of open() and do something intelligent with it.
369           open($fh, '-|', 'monkeysphere', 'keys-for-userid', $uid);
370           while(<$fh>) {
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");
374               next;
375             }
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);
382             }
383           }
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", $!);
388           }
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?
392           }
393
394         }
395       } else {
396         msvalog('error', "failed to decode %s\n", unpack('H*', $cert->pubkey()));
397         $ret->{message} = sprintf('failed to decode the public key', $uid);
398       }
399     }
400
401     return $status, $ret;
402   }
403
404   sub child_dies {
405     my $self = shift;
406     my $pid = shift;
407     my $server = shift;
408
409     msvalog('debug', "Subprocess %d terminated.\n", $pid);
410
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();
418     }
419   }
420
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.
424   sub untaint {
425     my $x = shift;
426     $x =~ /^(.*)$/ ;
427     return $1;
428   }
429
430   sub post_bind_hook {
431     my $self = shift;
432     my $server = shift;
433
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();
439     }
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();
445     }
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();
450     }
451     $self->port($port);
452
453     my $argcount = @ARGV;
454     if ($argcount) {
455       $self->{child_pid} = 0; # indicate that we are planning to fork.
456       my $fork = fork();
457       if (! defined $fork) {
458         msvalog('error', "could not fork\n");
459       } else {
460         if ($fork) {
461           msvalog('debug', "Child process has PID %d\n", $fork);
462           $self->{child_pid} = $fork;
463         } else {
464           msvalog('verbose', "PID %d executing: \n", $$);
465           for my $arg (@ARGV) {
466             msvalog('verbose', " %s\n", $arg);
467           }
468           $ENV{PATH} = untaint($ENV{PATH});
469           my @args;
470           foreach (@ARGV) {
471             push @args, untaint($_);
472           }
473           $ENV{MONKEYSPHERE_VALIDATION_AGENT_SOCKET} = sprintf('http://localhost:%d', $self->port);
474           exec(@args) or exit 111;
475         }
476       }
477     } else {
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.
482     };
483   }
484
485   sub extracerts {
486     my $data = shift;
487
488     return '500 not yet implemented', { };
489   }
490
491   1;
492 }
493
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(),
499              msva=>$server);
500 __END__
501
502 =head1 NAME
503
504 msva-perl - Perl implementation of a Monkeysphere Validation Agent
505
506 =head1 SYNOPSIS
507
508   msva-perl [ COMMAND [ ARGS ... ] ]
509
510 =head1 ABSTRACT
511
512 msva-perl provides a Perl implementation of the Monkeysphere
513 Validation Agent, a certificate validation service.
514
515 =head1 INTRODUCTION
516
517 The Monkeysphere Validation Agent offers a local service for tools to
518 validate certificates (both X.509 and OpenPGP) and other public keys.
519
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).
524
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
527 context.
528
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.
531
532 =head1 USAGE
533
534 Launched with no arguments, msva-perl simply runs and listens forever.
535
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
541 subprocess.
542
543 This is a similar invocation pattern to that of ssh-agent(1).
544
545 =head1 ENVIRONMENT VARIABLES
546
547 msva-perl is configured by means of environment variables.
548
549 =over 4
550
551 =item MSVA_LOG_LEVEL
552
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'.
557
558 =item MSVA_ALLOWED_USERS
559
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.
565
566 =item MSVA_PORT
567
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.
571
572 =back
573
574 =head1 COMMUNICATION PROTOCOL DETAILS
575
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
581
582 =head1 SECURITY CONSIDERATIONS
583
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).
589
590 =head1 SEE ALSO
591
592 monkeysphere(1), monkeysphere(7), ssh-agent(1)
593
594 =head1 BUGS AND FEEDBACK
595
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/
599
600 =head1 AUTHORS AND CONTRIBUTORS
601
602 Daniel Kahn Gillmor E<lt>dkg@fifthhorseman.net<gt>
603
604 The Monkeysphere Team http://web.monkeysphere.info/
605
606 =head1 COPYRIGHT AND LICENSE
607
608 Copyright © Daniel Kahn Gillmor and others from the Monkeysphere team.
609 msva-perl is free software, distributed under the GNU Public License,
610 version 3 or later.
611