#!/usr/bin/perl
# SSHARK reference implementation (server side component)
# Version 0.1.1, released 28 December 2012
# ----- See http://sshark.org/ -----
# Written by Anatole Shaw; ash AT greenhost DOT nl
# Copyright (C)2012 Greenhost VOF; https://greenhost.nl/
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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, see .
use strict;
use Switch;
use File::Temp qw(tempfile);
use Sys::Syslog;
use Getopt::Long;
use MIME::Base64;
use Digest::SHA qw(sha256_hex);
use IO::Select;
use POSIX qw(strftime);
use Net::DNS;
use Net::DNS::RR;
use Net::DNS::RR::TXT;
use Authen::PAM qw(:constants);
#my $debug = 1;
my $debug;
my $dnstimeout = 5;
umask(077);
$ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin';
setproctitle();
openlog('sshark','pid','auth');
sub setproctitle {
# horrible hack around syslog bug in Authen::PAM
switch ($_[0]) {
case 'pam' { $0 = "sshark\0\0\0sshark[$$]"; }
else { $0 = "sshark [$ENV{USER}]"; }
}
}
sub fatal {
print STDERR "$0: fatal: @_\n";
syslog('alert',"fatal: @_");
closelog();
exit(2);
}
sub deny {
my $keytype = $_[0];
my $keyhash = $_[1];
my $zone = $_[2];
my $msg = $_[3];
print STDERR "================================[sshark]================================\n";
print STDERR "Sorry, your SSH key ${msg}.\n";
print STDERR "Key: $keytype $keyhash\n";
print STDERR "Zone: $zone\n";
print STDERR "========================================================================\n";
syslog('warning',"denied user '$ENV{USER}' with $keytype key $keyhash from $ENV{SSH_CLIENT}");
closelog();
exit(3);
}
sub note {
print STDERR "[sshark] @_\n";
}
sub warning {
print STDERR "[sshark] warning: @_\n";
syslog('warning',"warning: @_");
}
sub debug {
if (defined($debug)) {
print STDERR " @_\n";
syslog('debug',"debug: @_");
}
}
sub tmpf {
my $tmp = File::Temp->new(
TEMPLATE => 'sshark-XXXXXXXXXXX',
SUFFIX => '.tmp',
UNLINK => 1
);
return $tmp;
}
## Take an SSH key type and fingerprint,
## and return the matching key and comment from ~/.ssh/authorized_keys
sub getpubkey {
my $spectype = $_[0];
my $spechash = $_[1];
my $pubkey;
my $comment;
my $AUTHKEYS;
my $authkeys = "$ENV{HOME}/.ssh/authorized_keys";
open(AUTHKEYS, "<$authkeys") or fatal("could not open $authkeys");
my $found = 0;
while () {
my $keyline = $_;
$keyline =~ s/.* ssh-/ssh-/g;
my $keytype = (split(/\s/,$keyline))[0];
next unless ( $keytype eq $spectype );
my $tmp = tmpf();
my $TMP;
open(TMP, ">".$tmp->filename);
print(TMP $keyline);
my $data=`ssh-keygen -l -f $tmp`;
my $keyhash = (split(/\s/,$data))[1];
if ( lc($keyhash) eq lc($spechash) ) {
$found = 1;
($pubkey,$comment) = (split(/\s/,$keyline))[1..2];
last;
}
close(TMP);
}
close(AUTHKEYS);
if ( $found == 1 ) {
return(($pubkey,$comment));
}
else {
return((undef,undef));
}
}
## Test whether a string is a valid lookup zone,
## which is nearly the same thing as a valid email address.
sub zonevalid {
if ( lc($_[0]) =~ /^[a-z0-9][a-z0-9-]*@[a-z0-9_][a-z0-9-]*(\.[a-z0-9_][a-z0-9-]*)*\.?$/ ) {
return(1);
}
else {
return(undef);
}
}
## Turn an SSH key comment field into a lookup zone.
sub comment2zone {
my $comment = $_[0];
if ( $comment =~ /^([^@]+)@([^@]+)$/ ) {
(my $cuser, my $cdom) = ($1,$2);
$cuser =~ tr[A-Za-z0-9-][-]c;
my $czone = lc("${cuser}\@${cdom}");
return (undef) unless ( zonevalid($czone) );
$czone =~ s/@/._sshark./;
return($czone);
}
else {
return(undef);
}
}
## Return the deeper zone (querybase) containing claims for a given key.
sub getquerybase {
my $keytype = $_[0];
my $keyhash = $_[1];
my $zone = $_[2];
my $keyhashx = $keyhash;
$keyhashx =~ s/://g;
my $querybase = "${keytype}-${keyhashx}.${zone}";
return $querybase;
}
## Extract the fields present in a SSHARK claim.
sub extractclaim {
my $text = $_[0];
my $type = $_[1];
if ( $text =~ /^sshark1 serial ([0-9]+) expiry ([0-9]+)$/ ) {
(my $serial, my $expiry) = ($1,$2);
my $claim = {serial=>$1,expiry=>$2,source=>$type};
debug("found claim $1, expiry $2, source $type");
return $claim;
}
}
## Get all the claims in a zone (querybase).
## Sort order: revocations first, then by descending serial number.
sub getclaims {
my $querybase = $_[0];
my $rrfile = $_[1];
my @claims;
if ( -r $rrfile ) {
my $RRFILE;
open(RRFILE,"<$rrfile");
while () {
if ( /^([^\s]+)\s+"([^"]+)"\s*$/ ) {
(my $rrname, my $rrdata) = ($1,$2);
if ( $rrname eq $querybase ) {
my $claim = extractclaim($rrdata,'file');
push(@claims, $claim);
}
}
}
close(RRFILE);
}
my $dns = Net::DNS::Resolver->new;
$dns->tcp_timeout($dnstimeout);
my $dnssock = $dns->bgsend($querybase,'TXT');
my $dnssel = IO::Select->new($dnssock);
my @sel = $dnssel->can_read($dnstimeout);
if (@sel) {
foreach my $sock (@sel) {
if ( $sock == $dnssock ) {
my $dnsq = $dns->bgread($sock);
foreach my $txt ($dnsq->answer) {
my $claim = extractclaim($txt->txtdata,'dns');
push(@claims, $claim);
}
}
}
}
else {
debug("DNS timeout");
}
@claims = sort {
if ( $a->{expiry} == 0 ) {
debug("prioritizing claim " . $a->{serial} . " because it is a revocation");
return (-1);
}
else {
return ( $b->{serial} <=> $a->{serial} );
}
} @claims;
return(\@claims);
}
## Return the signature data associated with a given claim.
sub getclaimval {
my $querybase = $_[0];
my $rrfile = $_[1];
my $claim = $_[2];
debug("looking in " . $claim->{source} . " for data on claim " . $claim->{serial});
my $rrsearch = 's'.$claim->{serial}.'.'.$querybase;
my $valdata;
switch ( $claim->{source} ) {
case 'file' {
my $RRFILE;
open(RRFILE,"<$rrfile");
while () {
if ( /^([^\s]+)\s+"([^"]+)"\s*$/ ) {
(my $rrname, my $rrdata) = ($1,$2);
debug("found: $rrname");
if ( $rrname eq $rrsearch && $rrdata =~ /^sshark1 data ([0-9A-Za-z\/+=]+)$/ ) {
#debug("found data in file $rrfile") if ( !defined($valdata) );
$valdata .= $1;
}
}
}
close(RRFILE);
}
case 'dns' {
my $dns = Net::DNS::Resolver->new;
$dns->tcp_timeout($dnstimeout);
$dns->usevc(1); #TCP
my $dnsq = $dns->query($rrsearch, "TXT");
warning("could not query DNS for revocation data") if (!defined($dnsq));
foreach my $txt ($dnsq->answer) {
if ( $txt->txtdata =~ /^sshark1 data ([0-9A-Za-z\/+=]+)$/ ) {
#debug("found data in DNS") if ( !defined($valdata) );
$valdata .= $1;
}
}
}
}
if (defined($valdata)) { $valdata = decode_base64($valdata); }
return $valdata;
}
## Validate the signature that was found on a claim.
sub chkclaimval {
my $valdata = $_[0];
my $keytype = $_[1];
my $pubkey = $_[2];
my $pempub = key2pem("$keytype $pubkey");
fatal("unable to convert public key to pkcs8") if (!defined($pempub));
my $pempubf = tmpf();
my $pempubfn = $pempubf->filename;
my $PEMPUBF;
open(PEMPUBF, ">$pempubfn");
print(PEMPUBF $pempub);
my $valdataf = tmpf();
my $valdatafn = $valdataf->filename;
my $REVODATAF;
open(REVODATAF, ">$valdatafn");
print(REVODATAF $valdata);
my $result = `openssl rsautl -verify -pubin -keyform PEM -inkey $pempubfn -in $valdatafn 2>/dev/null`;
close(PEMPUBF);
close(REVODATAF);
chomp $result;
return $result;
}
## Turn an SSH public key into PEM format.
## This depends on ssh-keygen(1) but we should do it ourselves.
sub key2pem {
my $retval;
my $tmp = tmpf();
my $TMP;
open(TMP, ">".$tmp->filename);
print(TMP @_);
my $tmpfn = $tmp->filename;
my $pkcs8 = `ssh-keygen -e -m pkcs8 -f $tmpfn`;
if ( $pkcs8 =~ /^-----BEGIN PUBLIC KEY-----/ ) {
$retval = $pkcs8;
}
else {
$retval = undef;
}
close(TMP);
return($retval);
}
## Simulate a full login as best we can.
sub loginfull {
my $pam = new Authen::PAM('sshark',$ENV{USER});
fatal("pam_start") if ( !defined($pam) );
my $pamerr;
setproctitle('pam'); $pamerr = $pam->pam_open_session(); setproctitle();
fatal("pam_open_session $pamerr") if ( $pamerr != 0 );
system($ENV{SHELL});
setproctitle('pam'); $pamerr = $pam->pam_close_session(); setproctitle();
fatal("pam_close_session $pamerr") if ( $pamerr != 0 );
}
## This is it.
my $keytype;
my $keyhash;
my $zone;
my $rrfile;
GetOptions(
'key-type|t=s' => \$keytype,
'key-hash|l=s' => \$keyhash,
'domain|d=s' => \$zone,
'rrfile|f=s' => \$rrfile,
);
sub usage {
print STDERR "USAGE: sshark [ -f ] [ -d ] -t -l \n";
exit(1);
}
if ( !defined($keytype) || !defined($keyhash) ) { usage() }
unless ( $keytype eq 'ssh-rsa' ) { fatal("only ssh-rsa keys are supported right now") }
if ( defined($zone) && !zonevalid($zone) ) { fatal("invalid zone '$zone'") }
if ( !defined($rrfile) ) { $rrfile = "/var/lib/sshark/sshark.dat" }
if ( !-r $rrfile ) { warning("file $rrfile is unreadable") }
(my $pubkey, my $comment) = getpubkey($keytype, $keyhash);
if ( !defined($pubkey) ) { fatal("specification matches no authorized key") }
if ( !defined($zone) ) {
if ( defined($comment) ) {
my $czone = comment2zone($comment);
if ( defined($czone) ) {
$zone = $czone;
}
else {
fatal("key has malformed comment '$comment'");
}
}
else {
fatal("key has no comment, and no zone specified on command line");
}
}
my $querybase = getquerybase($keytype, $keyhash, $zone);
debug("querybase $querybase");
my $valid = 0;
my $claims = getclaims($querybase,$rrfile);
if ( @$claims ) {
CLAIM: foreach my $claim (@$claims) {
debug("evaluating claim " . $claim->{serial});
my $valdata = getclaimval($querybase,$rrfile,$claim);
if ( defined($valdata) ) {
my $claimstr = sha256_hex('sshark1 serial '.$claim->{serial}.' expiry '.$claim->{expiry});
my $signedmsg = chkclaimval($valdata, $keytype, $pubkey);
if ( $signedmsg eq $claimstr ) {
debug("claim " . $claim->{serial} . " is authentic");
if ( $claim->{expiry} == 0 ) {
deny($keytype,$keyhash,$zone,"has been revoked");
}
elsif ( $claim->{expiry} <= time() ) {
my $expired = strftime("%a %b %e %H:%M:%S %Y %Z (%z)", localtime($claim->{expiry}));
deny($keytype,$keyhash,$zone,"expired on $expired");
}
else {
note("Your key is valid until " .
strftime("%a %b %e %H:%M:%S %Y %Z (%z)", localtime($claim->{expiry})));
$valid = 1;
last CLAIM;
}
}
else {
debug("claim " . $claim->{serial} . " failed authenticity check, skipping");
}
}
else {
warning("claim " . $claim->{serial} . " has no associated data, skipping");
}
}
}
else {
deny($keytype,$keyhash,$zone,"has no sshark records");
}
if ( $valid == 1 ) {
closelog();
my $origcmd = $ENV{SSH_ORIGINAL_COMMAND};
if ( defined($origcmd) ) {
if ( $origcmd eq 'internal-sftp' ) {
debug("requested internal-sftp");
exec "/usr/lib/sftp-server";
}
else {
debug("executing: $origcmd");
exec "$origcmd";
}
}
else {
debug("performing full login");
loginfull();
}
}
else {
deny($keytype,$keyhash,$zone,"has no authentic sshark records");
}