# based on cap_sasl.pl by Michael Tharp and Jilles Tjoelker
# ported to X-Chat 2 by Lian Wan Situ
#
# license: GNU General Public License
# Latest version at http://lwsitu.com/xchat/cap_sasl_xchat.pl

### Configuration #######
# How long to wait between authentication messages
my $AUTHENTICATION_TIMEOUT = 5;
### End Configuration ###

use strict;
use warnings;
use Xchat qw(:all);

use MIME::Base64;

register(
	"CAP SASL",
	"1.0300",
	"Implements PLAIN SASL authentication mechanism for use with charybdis ircds, and enables CAP MULTI-PREFIX IDENTIFY-MSG",
	\&cmd_sasl_save,
);


my %timeouts;
my %processing_cap;
hook_print( "Connected", \&event_connected );

hook_server( 'CAP', \&event_cap);
hook_server( 'AUTHENTICATE', \&event_authenticate);
hook_server( '900', sub {
	cap_out( substr( $_[1][5], 1, ) );
	timeout_remove();
	return EAT_XCHAT;
});
hook_server( '903', \&event_saslsuccess);
hook_server( '904', \&event_saslend);
hook_server( '905', \&event_saslend);
hook_server( '906', \&event_saslend);
hook_server( '907', \&event_saslend);

hook_command( 'SASL', \&cmd_sasl, { help_text => cmd_sasl_help_text() } );

my $AUTH_TIMEOUT;
if( $AUTHENTICATION_TIMEOUT ) {
	$AUTH_TIMEOUT = $AUTHENTICATION_TIMEOUT * 1_000;
} else {
	$AUTH_TIMEOUT = 5_000;
}

my %sasl_auth = ();
my %mech = ();

sub send_raw {
	commandf( "QUOTE %s", $_[0] );
}

sub cap {
	send_raw( "CAP " . $_[0] );
}

sub cap_end{
	delete $processing_cap{ get_info "id" };
	cap( "END" );
}

sub connected {
	my $flags = context_info->{flags};
	return  $flags & 1 || $flags & 2;
}

sub get_config_file {
	return get_info( "xchatdirfs" )."/sasl.auth";
}

sub network_name {
	return lc get_info( "network" );
}

# switch to the server tab for the current connection
# return true if successful
sub switch_to_server {
	my $connection_id = shift;

	for my $tab( get_list "channels") {
		if( $tab->{id} == $connection_id && $tab->{type} == 1 ) {
			return set_context( $tab->{context} );
		}
	}

	return;
}

sub cap_out {
	my $output = shift;
	switch_to_server( get_info "id" );

	prnt( $output );
}

sub event_connected {
	cap( "LS" );

	# reset everything for new connection
	timeout_remove();
	delete $processing_cap{ get_info( "id" ) };
	return EAT_NONE;
}

sub event_cap {
	my $tosend = '';
	my $subcmd = uc $_[0][3];
	my $caps = $_[1][4];
	$caps =~ s/^://;

	if ($subcmd eq 'LS') {
		my $id = get_info "id";
		if( $processing_cap{ $id } ) {
			return EAT_XCHAT;
		}
		$processing_cap{ $id } = 1;
		$tosend .= ' multi-prefix' if $caps =~ /\bmulti-prefix\b/xi;

		if( $caps =~ /\bsasl\b/xi ) {
			if( defined($sasl_auth{network_name()}) ) {
				$tosend .= ' sasl';
			} else {
				cap_out( "\cC05SASL is supported but there is no authentication information set for this network(\cC02".network_name()."\cC05)." );
			}
		}

		$tosend .= ' identify-msg' if $caps =~ /\bidentify-msg\b/;
		$tosend =~ s/^ //;
		cap_out( "CLICAP: supported by server: $caps" );

		if ( connected() ) {
			if ($tosend eq '') {
				cap_end();
			} else {
				cap_out( "CLICAP: requesting: $tosend" );
				cap( "REQ :$tosend" );
			}
		}
	} elsif( $subcmd eq 'ACK' ) {
		cap_out( "CLICAP: now enabled: $caps" );

		if( $caps =~ /\bidentify-msg\b/i ) {
			commandf( "RECV %s 290 %s :IDENTIFY-MSG",
				$_[0][0], get_info( "nick" ) );
		}

		if( $caps =~ /\bsasl\b/i ) {
			$sasl_auth{network_name()}{buffer} = '';
			if($mech{$sasl_auth{network_name()}{mech}}) {
				send_raw( "AUTHENTICATE "
					. $sasl_auth{network_name()}{mech}
				);

				timeout_start();
			} else {
				cap_out( 'SASL: attempted to start unknown mechanism "%s"',
					$sasl_auth{network_name()}{mech}
				);
			}
		} elsif( connected() ) {
			cap_end;
		}
	} elsif( $subcmd eq 'NAK' ) {
		cap_out( "CLICAP: refused:$caps" );
		if ( connected() ) {
			cap_end;
		}
	} elsif( $subcmd eq 'LIST' ) {
		cap_out( "CLICAP: currently enabled:$caps" );
	}

	return EAT_XCHAT;
}

sub event_authenticate {
	my $args = $_[1][1] || "";

	my $sasl = $sasl_auth{network_name()};
	return EAT_XCHAT unless $sasl && $mech{$sasl->{mech}};

	$sasl->{buffer} .= $args;
	timeout_reset();
	return EAT_XCHAT if length($args) == 400;

	my $data = $sasl->{buffer} eq '+' ? '' : decode_base64($sasl->{buffer});
	my $out = $mech{$sasl->{mech}}($sasl, $data);
	$out = '' unless defined $out;
	$out = $out eq '' ? '+' : encode_base64($out, '');

	while(length $out >= 400) {
		my $subout = substr($out, 0, 400, '');
		send_raw("AUTHENTICATE $subout");
	}
	if(length $out) {
		send_raw("AUTHENTICATE $out");
	}else{ # Last piece was exactly 400 bytes, we have to send some padding to indicate we're done
		send_raw("AUTHENTICATE +");
	}

	$sasl->{buffer} = '';
	return EAT_XCHAT;
}

sub event_saslsuccess {
   my $data = $_[1][3];
   $data =~ s/^://;

   cap_out( $data );
   if( connected() ) {
	  cap_end();
   }

   return EAT_XCHAT;
}
sub event_saslend {
	my $data = $_[1][1];
	$data =~ s/^\S+ :?//;
	
	if (connected()) {
		cap_end();
	}

	return EAT_XCHAT;
}

sub timeout_start {
	$timeouts{ context_info->{id} }
		= hook_timer( $AUTH_TIMEOUT, sub { timeout(); return REMOVE; } );
}

sub timeout_remove {
	unhook( $timeouts{ context_info->{id} } ) if $timeouts{ context_info->{id} };
}

sub timeout_reset {
	timeout_remove();
	timeout_start();
}

sub timeout {
	my $id = get_info "id";
	delete $processing_cap{ $id };

	if( connected() ) {
		cap_out( "SASL: authentication timed out" );
		cap_end();
	}
}

my %sasl_actions = (
	load => \&cmd_sasl_load,
	save => \&cmd_sasl_save,
	set => \&cmd_sasl_set,
	delete => \&cmd_sasl_delete,
	show => \&cmd_sasl_show,
	help => \&cmd_sasl_help,
	mechanisms => \&cmd_sasl_mechanisms,
);

sub cmd_sasl {
	my $action = $_[0][1];

	if( $action and my $action_code = $sasl_actions{ $action } ) {
		$action_code->( @_ );
	} else {
		$sasl_actions{ help }->( @_ );
	}

	return EAT_XCHAT;
}

sub cmd_sasl_help_text {
	return <<"HELP_TEXT";
SASL [action] [action paramters]
    actions:
    load        reload SASL information from disk
    save        save the current SASL information to disk
    set         set the SASL information for a particular network
        set <net> <user> <password or keyfile> <mechanism>
            you can also set <net> to -n or -- to use the name of the network from currently focused tab
            you can also set <user> to -u or -- to use the current nick
    delete      delete the SASL information for a particular network
        delete <net>

    show        display which networks have SASL information set
    mechanisms  display supported mechanisms

    help        show help message
HELP_TEXT

}

sub cmd_sasl_set {
	my $data = $_[ 1 ][ 2 ] || "";

	if( my ($net, $u, $p, $m) = $data =~ /^(\S+)\s+(\S+)\s+(\S+)(?:\s+(\S+))?$/ ) {
		$m = "plain" unless defined $m;
		$m = uc $m;

		if( $mech{ $m } ) {
			if( $net =~ /^-[-n]$/ ) {
				$net = get_info( "network" );

				unless ( defined $net ) {
					prnt "SASL: Unable to get network name. Make sure the current tab is connected to a server.";
					return;
				}
			}
			$net = lc $net;

			if( $u =~ /^-[-u]$/ ) {
				$u = get_info( "nick" );
			}
			$sasl_auth{ $net }{ user }     = $u;
			$sasl_auth{ $net }{ password } = $p;
			$sasl_auth{ $net }{ mech }     = $m;
			prnt( "SASL: added $net: [$m] $sasl_auth{$net}{user} *" );
		} else {
			prnt( "SASL: unknown mechanism $m" );
		}
	} elsif ( $data =~ /^(\S+)$/ ) {
		$net = $1;
		delete_net( $net );
	} else {
		prnt( "SASL: usage: /sasl set <net> <user> <password or keyfile> <mechanism>" );
	}
}

sub delete_net {
   my $net = lc shift;

   if( $net =~ /^-[-n]$/ ) {
	  $net = lc get_info( "network" );

	  unless( defined $net ) {
		 prnt "SASL: Unable to get network name. Make sure the current tab is connected to a server.";
		 return;
	  }
   }

   if (defined($sasl_auth{$net})) {
	  delete $sasl_auth{$net};
	  prnt( "SASL: deleted $net" );
   } else {
	  prnt( "SASL: no entry for $net" );
   }

}
sub cmd_sasl_delete {
   my $net = $_[0][2];

   delete_net( $net );
}

sub cmd_sasl_show {
	foreach my $net (keys %sasl_auth) {
		prnt( "SASL: $net: [$sasl_auth{$net}{mech}] $sasl_auth{$net}{user} *" );
	}
	prnt("SASL: no networks defined") if !%sasl_auth;
}

sub cmd_sasl_save {
	my $file = get_config_file();
	
	if( open my $fh, ">", $file ) {

	foreach my $net (keys %sasl_auth) {
		printf $fh ("%s\t%s\t%s\t%s\n", lc $net, $sasl_auth{$net}{user}, $sasl_auth{$net}{password}, $sasl_auth{$net}{mech});
	}

	prnt( "SASL: auth saved to $file" );
	} else {
		prnt qq{Couldn't open '$file' to save auth data: $!};
	}
}

sub cmd_sasl_load {
	#my ($data, $server, $item) = @_;
	my $file = get_config_file();

	open FILE, "< $file" or return;
	%sasl_auth = ();
	while (<FILE>) {
		chomp;
		my ($net, $u, $p, $m) = split (/\t/, $_, 4);
		$m ||= "PLAIN";
		$net = lc $net;
		if($mech{uc $m}) {
			$sasl_auth{$net}{user} = $u;
			$sasl_auth{$net}{password} = $p;
			$sasl_auth{$net}{mech} = uc $m;
		}else{
			prnt( "SASL: unknown mechanism $m" );
		}
	}
	close FILE;
	prnt( "SASL: auth loaded from $file" );
}

sub cmd_sasl_mechanisms {
	prnt( "SASL: mechanisms supported: " . join(" ", keys %mech) );
}

sub cmd_sasl_help {
	prnt( cmd_sasl_help_text() );
}

$mech{PLAIN} = sub {
	my($sasl, $data) = @_;
	my $u = $sasl->{user};
	my $p = $sasl->{password};

	join("\0", $u, $u, $p);
};

# binary to BigInt
sub bin2bi {
	return Crypt::OpenSSL::Bignum
		->new_from_bin(shift)
		->to_decimal;
}

# BigInt to binary
sub bi2bin {
	return Crypt::OpenSSL::Bignum
		->new_from_decimal((shift)->bstr)
		->to_bin;
}

eval {
	require Crypt::OpenSSL::Bignum;
	require Crypt::DH;
	require Crypt::Blowfish;
	require Math::BigInt;

	$mech{'DH-BLOWFISH'} = sub {
		my($sasl, $data) = @_;
		my $u = $sasl->{user};
		my $pass = $sasl->{password};

		# Generate private key and compute secret key
		my($p, $g, $y) = unpack("(n/a*)3", $data);
		my $dh = Crypt::DH->new(p => bin2bi($p), g => bin2bi($g));
		$dh->generate_keys;

		my $secret = bi2bin($dh->compute_secret(bin2bi($y)));
		my $pubkey = bi2bin($dh->pub_key);

		# Pad the password to the nearest multiple of blocksize and encrypt
		$pass .= "\0";
		$pass .= chr(rand(256)) while length($pass) % 8;

		my $cipher = Crypt::Blowfish->new($secret);
		my $crypted = '';
		while(length $pass) {
			my $clear = substr($pass, 0, 8, '');
			$crypted .= $cipher->encrypt($clear);
		}

		pack("n/a*Z*a*", $pubkey, $u, $crypted);
	};
};

cmd_sasl_load();

# vim: ts=4
