diff options
Diffstat (limited to 'scripts/cap_sasl.pl')
-rw-r--r-- | scripts/cap_sasl.pl | 437 |
1 files changed, 437 insertions, 0 deletions
diff --git a/scripts/cap_sasl.pl b/scripts/cap_sasl.pl new file mode 100644 index 0000000..7a0b742 --- /dev/null +++ b/scripts/cap_sasl.pl @@ -0,0 +1,437 @@ +use strict; +use Irssi; +use MIME::Base64; +use vars qw($VERSION %IRSSI); +use constant CHALLENGE_SIZE => 32; + +$VERSION = "1.11"; +%IRSSI = ( + authors => 'Michael Tharp (gxti), Jilles Tjoelker (jilles), Mantas Mikulėnas (grawity)', + contact => 'grawity@gmail.com', + name => 'cap_sasl.pl', + description => 'Implements SASL authentication and enables CAP "multi-prefix"', + license => 'GPLv2', + url => 'http://ircv3.atheme.org/extensions/sasl-3.1', +); + +my %sasl_auth = (); +my %mech = (); + +sub irssi_abspath { + my $f = shift; + $f =~ s!^~/!$ENV{HOME}/!; + if ($f !~ m!^/!) { + $f = Irssi::get_irssi_dir()."/".$f; + } + return $f; +} + +sub timeout; + +sub server_connected { + my $server = shift; + if (uc $server->{chat_type} eq 'IRC') { + $server->send_raw_now("CAP LS"); + } +} + +sub event_cap { + my ($server, $args, $nick, $address) = @_; + my ($subcmd, $caps, $tosend, $sasl); + + $tosend = ''; + $sasl = $sasl_auth{$server->{tag}}; + if ($args =~ /^\S+ (\S+) :(.*)$/) { + $subcmd = uc $1; + $caps = ' '.$2.' '; + if ($subcmd eq 'LS') { + $tosend .= ' multi-prefix' if $caps =~ / multi-prefix /i; + $tosend .= ' sasl' if $caps =~ / sasl /i && defined($sasl); + $tosend =~ s/^ //; + $server->print('', "CLICAP: supported by server:$caps"); + if (!$server->{connected}) { + if ($tosend eq '') { + $server->send_raw_now("CAP END"); + } else { + $server->print('', "CLICAP: requesting: $tosend"); + $server->send_raw_now("CAP REQ :$tosend"); + } + } + Irssi::signal_stop(); + } elsif ($subcmd eq 'ACK') { + $server->print('', "CLICAP: now enabled:$caps"); + if ($caps =~ / sasl /i) { + $sasl->{buffer} = ''; + $sasl->{step} = 0; + if ($mech{$sasl->{mech}}) { + $server->send_raw_now("AUTHENTICATE " . $sasl->{mech}); + Irssi::timeout_add_once(7500, \&timeout, $server->{tag}); + } else { + $server->print('', 'SASL: attempted to start unknown mechanism "' . $sasl->{mech} . '"'); + } + } + elsif (!$server->{connected}) { + $server->send_raw_now("CAP END"); + } + Irssi::signal_stop(); + } elsif ($subcmd eq 'NAK') { + $server->print('', "CLICAP: refused:$caps"); + if (!$server->{connected}) { + $server->send_raw_now("CAP END"); + } + Irssi::signal_stop(); + } elsif ($subcmd eq 'LIST') { + $server->print('', "CLICAP: currently enabled:$caps"); + Irssi::signal_stop(); + } + } +} + +sub event_authenticate { + my ($server, $args, $nick, $address) = @_; + my $sasl = $sasl_auth{$server->{tag}}; + return unless $sasl && $mech{$sasl->{mech}}; + + $sasl->{buffer} .= $args; + return if length($args) == 400; + + my $data = ($sasl->{buffer} eq '+') ? '' : decode_base64($sasl->{buffer}); + my $out = $mech{$sasl->{mech}}($sasl, $data); + + if (defined $out) { + $out = ($out eq '') ? '+' : encode_base64($out, ''); + while (length $out >= 400) { + my $subout = substr($out, 0, 400, ''); + $server->send_raw_now("AUTHENTICATE $subout"); + } + if (length $out) { + $server->send_raw_now("AUTHENTICATE $out"); + } else { + # Last piece was exactly 400 bytes, we have to send + # some padding to indicate we're done. + $server->send_raw_now("AUTHENTICATE +"); + } + } else { + $server->send_raw_now("AUTHENTICATE *"); + } + + $sasl->{buffer} = ""; + Irssi::signal_stop(); +} + +sub event_saslend { + my ($server, $args, $nick, $address) = @_; + + my $data = $args; + $data =~ s/^\S+ :?//; + # need this to see it, ?? -- jilles + + $server->print('', $data); + if (!$server->{connected}) { + $server->send_raw_now("CAP END"); + } +} + +sub event_saslfail { + my ($server, $args, $nick, $address) = @_; + + my $data = $args; + $data =~ s/^\S+ :?//; + + if (Irssi::settings_get_bool('sasl_disconnect_on_fail')) { + $server->print('', "$data - disconnecting from server", MSGLEVEL_CLIENTERROR); + $server->disconnect(); + } else { + $server->print('', "$data - continuing anyway"); + if (!$server->{connected}) { + $server->send_raw_now("CAP END"); + } + } +} + +sub timeout { + my $tag = shift; + my $server = Irssi::server_find_tag($tag); + if ($server && !$server->{connected}) { + $server->print('', "SASL: authentication timed out", MSGLEVEL_CLIENTERROR); + $server->send_raw_now("CAP END"); + } +} + +sub cmd_sasl { + my ($data, $server, $item) = @_; + + if ($data ne '') { + Irssi::command_runsub ('sasl', $data, $server, $item); + } else { + cmd_sasl_show(@_); + } +} + +sub cmd_sasl_set { + my ($data, $server, $item) = @_; + + if (my ($net, $u, $p, $m) = $data =~ /^(\S+) (\S+) (\S+) (\S+)$/) { + if ($mech{uc $m}) { + $sasl_auth{$net}{user} = $u; + $sasl_auth{$net}{password} = $p; + $sasl_auth{$net}{mech} = uc $m; + Irssi::print("SASL: added $net: [$m] $sasl_auth{$net}{user} *"); + } else { + Irssi::print("SASL: unknown mechanism $m", MSGLEVEL_CLIENTERROR); + } + } elsif ($data =~ /^(\S+)$/) { + $net = $1; + if (defined($sasl_auth{$net})) { + delete $sasl_auth{$net}; + Irssi::print("SASL: deleted $net"); + } else { + Irssi::print("SASL: no entry for $net"); + } + } else { + Irssi::print("SASL: usage: /sasl set <net> <user> <password or keyfile> <mechanism>"); + } +} + +sub cmd_sasl_show { + #my ($data, $server, $item) = @_; + my @nets = keys %sasl_auth; + for my $net (@nets) { + Irssi::print("SASL: $net: [$sasl_auth{$net}{mech}] $sasl_auth{$net}{user} *"); + } + Irssi::print("SASL: no networks defined") if !@nets; +} + +sub cmd_sasl_save { + #my ($data, $server, $item) = @_; + my $file = Irssi::get_irssi_dir()."/sasl.auth"; + if (open(my $fh, ">", $file)) { + chmod(0600, $file); + for my $net (keys %sasl_auth) { + printf $fh ("%s\t%s\t%s\t%s\n", + $net, + $sasl_auth{$net}{user}, + $sasl_auth{$net}{password}, + $sasl_auth{$net}{mech}); + } + close($fh); + Irssi::print("SASL: auth saved to '$file'"); + } else { + Irssi::print("SASL: couldn't access '$file': $@"); + } +} + +sub cmd_sasl_load { + #my ($data, $server, $item) = @_; + my $file = Irssi::get_irssi_dir()."/sasl.auth"; + if (open(my $fh, "<", $file)) { + %sasl_auth = (); + while (<$fh>) { + chomp; + my ($net, $u, $p, $m) = split(/\t/, $_, 4); + $m ||= "PLAIN"; + if ($mech{uc $m}) { + $sasl_auth{$net}{user} = $u; + $sasl_auth{$net}{password} = $p; + $sasl_auth{$net}{mech} = uc $m; + } else { + Irssi::print("SASL: unknown mechanism $m", MSGLEVEL_CLIENTERROR); + } + } + close($fh); + Irssi::print("SASL: cap_sasl $VERSION, auth loaded from '$file'"); + } +} + +sub cmd_sasl_mechanisms { + Irssi::print("SASL: mechanisms supported: " . join(", ", sort keys %mech)); +} + +Irssi::settings_add_bool('server', 'sasl_disconnect_on_fail', 1); + +Irssi::signal_add_first('server connected', \&server_connected); +Irssi::signal_add('event cap', \&event_cap); +Irssi::signal_add('event authenticate', \&event_authenticate); +Irssi::signal_add('event 903', \&event_saslend); +Irssi::signal_add('event 904', \&event_saslfail); +Irssi::signal_add('event 905', \&event_saslend); +Irssi::signal_add('event 906', \&event_saslfail); +Irssi::signal_add('event 907', \&event_saslend); + +Irssi::command_bind('sasl', \&cmd_sasl); +Irssi::command_bind('sasl load', \&cmd_sasl_load); +Irssi::command_bind('sasl save', \&cmd_sasl_save); +Irssi::command_bind('sasl set', \&cmd_sasl_set); +Irssi::command_bind('sasl show', \&cmd_sasl_show); +Irssi::command_bind('sasl mechanisms', \&cmd_sasl_mechanisms); + +$mech{PLAIN} = sub { + my ($sasl, $data) = @_; + my $u = $sasl->{user}; + my $p = $sasl->{password}; + return join("\0", $u, $u, $p); +}; + +$mech{EXTERNAL} = sub { + my ($sasl, $data) = @_; + return $sasl->{user} // ""; +}; + +if (eval {require Crypt::PK::ECC}) { + my $mech = "ECDSA-NIST256P-CHALLENGE"; + + $mech{'ECDSA-NIST256P-CHALLENGE'} = sub { + my ($sasl, $data) = @_; + my $u = $sasl->{user}; + my $f = $sasl->{password}; + $f = irssi_abspath($f); + if (!-f $f) { + Irssi::print("SASL: key file '$f' not found", MSGLEVEL_CLIENTERROR); + return; + } + my $pk = eval {Crypt::PK::ECC->new($f)}; + if ($@ || !$pk || !$pk->is_private) { + Irssi::print("SASL: no private key in file '$f'", MSGLEVEL_CLIENTERROR); + return; + } + my $step = ++$sasl->{step}; + if ($step == 1) { + if (length $data == CHALLENGE_SIZE) { + my $sig = $pk->sign_hash($data); + return $u."\0".$u."\0".$sig; + } elsif (length $data) { + return; + } else { + return $u."\0".$u; + } + } + elsif ($step == 2) { + if (length $data == CHALLENGE_SIZE) { + return $pk->sign_hash($data); + } else { + return; + } + } + }; + + Irssi::command_bind("sasl keygen" => sub { + my ($data, $server, $witem) = @_; + + my $print = $server + ? sub { $server->print("", shift, shift // MSGLEVEL_CLIENTNOTICE) } + : sub { Irssi::print(shift, shift // MSGLEVEL_CLIENTNOTICE) }; + + my $net = $server ? $server->{tag} : $data; + if (!length $net) { + Irssi::print("SASL: please connect to a server first", + MSGLEVEL_CLIENTERROR); + return; + } + + my $f_name = lc "sasl-ecdsa-$net"; + $f_name =~ s![ /]+!_!g; + my $f_priv = Irssi::get_irssi_dir()."/$f_name.key"; + my $f_pub = Irssi::get_irssi_dir()."/$f_name.pub"; + if (-e $f_priv) { + $print->("SASL: refusing to overwrite '$f_priv'", MSGLEVEL_CLIENTERROR); + return; + } + + $print->("SASL: generating keypair for '$net'..."); + my $pk = Crypt::PK::ECC->new; + $pk->generate_key("prime256v1"); + + my $priv = $pk->export_key_pem("private"); + my $pub = encode_base64($pk->export_key_raw("public_compressed"), ""); + + if (open(my $fh, ">", $f_priv)) { + chmod(0600, $f_priv); + print $fh $priv; + close($fh); + $print->("SASL: wrote private key to '$f_priv'"); + } else { + $print->("SASL: could not write '$f_priv': $!", MSGLEVEL_CLIENTERROR); + return; + } + + if (open(my $fh, ">", $f_pub)) { + print $fh $pub."\n"; + close($fh); + } else { + $print->("SASL: could not write '$f_pub': $!", MSGLEVEL_CLIENTERROR); + } + + my $cmdchar = substr(Irssi::settings_get_str("cmdchars"), 0, 1); + my $cmd = "msg NickServ SET PUBKEY $pub"; + + if ($server) { + $print->("SASL: updating your Irssi settings..."); + $sasl_auth{$net}{user} //= $server->{nick}; + $sasl_auth{$net}{password} = "$f_name.key"; + $sasl_auth{$net}{mech} = $mech; + cmd_sasl_save(@_); + $print->("SASL: submitting pubkey to NickServ..."); + $server->command($cmd); + } else { + $print->("SASL: update your Irssi settings:"); + $print->("%P".$cmdchar."sasl set $net <nick> $f_name.key $mech"); + $print->("SASL: submit your pubkey to $net:"); + $print->("%P".$cmdchar.$cmd); + } + }); + + Irssi::command_bind("sasl pubkey" => sub { + my ($data, $server, $witem) = @_; + + my $arg = $server ? $server->{tag} : $data; + + my $f; + if (!length $arg) { + Irssi::print("SASL: please select a server or specify a keyfile path", + MSGLEVEL_CLIENTERROR); + return; + } elsif ($arg =~ m![/.]!) { + $f = $arg; + } else { + if ($sasl_auth{$arg}{mech} eq $mech) { + $f = $sasl_auth{$arg}{password}; + } else { + $f = lc "sasl-ecdsa-$arg"; + $f =~ s![ /]+!_!g; + $f = "$f.key"; + } + } + + $f = irssi_abspath($f); + if (!-e $f) { + Irssi::print("SASL: keyfile '$f' not found", MSGLEVEL_CLIENTERROR); + return; + } + + my $pk = eval {Crypt::PK::ECC->new($f)}; + if ($@ || !$pk || !$pk->is_private) { + Irssi::print("SASL: no private key in file '$f'", MSGLEVEL_CLIENTERROR); + Irssi::print("(keys using named parameters or PKCS#8 are not yet supported)", + MSGLEVEL_CLIENTERROR); + return; + } + + my $pub = encode_base64($pk->export_key_raw("public_compressed"), ""); + Irssi::print("SASL: loaded keyfile '$f'"); + Irssi::print("SASL: your pubkey is $pub"); + }); +} else { + Irssi::command_bind("sasl keygen" => sub { + Irssi::print("SASL: cannot '/sasl keygen' as the Perl 'CryptX' module is missing", + MSGLEVEL_CLIENTERROR); + }); + + Irssi::command_bind("sasl pubkey" => sub { + Irssi::print("SASL: cannot '/sasl pubkey' as the Perl 'CryptX' module is missing", + MSGLEVEL_CLIENTERROR); + }); +} + +cmd_sasl_load(); + +# vim: ts=4:sw=4 |