# vim: set tabstop=4
#
# Copyright 2006 James Seward <james@jamesoff.net>
# Copyright 2025 by Marco d'Itri <md@linux.it>
#
# Usage: /CHALLENGE [<opernick> [passphrase]]

# Generate your private key with a command like:
#   openssl genrsa -out private.key -aes256 2048
#
# And then the public key from the private key:
#   openssl rsa -in private.key -out public.key -pubout
#
# The key passphrase may be changed later with:
#   openssl rsa -aes256 -in private.key -out newprivate.key
#
# Set ro_challenge_keyfile to the path of the private key file:
#   /set ro_challenge_keyfile ~/.irssi/
#
# Optionally, set ro_challenge_pass_program to a program (i.e. a password
# manager) which will emit the passphrase for the private key:
#   /set ro_challenge_pass_program pass show irc/%c/challenge_pass
#
# The %c and %n parameters may be used and are replaced respectively
# by the network name and nick. Beware: this string is fed to the shell!

# Thanks fly out to:
# zap, who wrote the POC ho_challenge.pl I reworked into this
#
# Changelog:
#
# v1.0 Initial version (James Seward)
# v1.1 Avoid leaving zombies (James Seward)
# v2.0 Stop using external programs and mostly rewritten (Marco d'Itri)

use v5.36;
use Irssi;
use Path::Tiny;
use MIME::Base64;
use Crypt::OpenSSL::RSA;
use Digest::SHA qw(sha1);

use vars qw($VERSION %IRSSI);

$VERSION = '20250204';
%IRSSI = (
	authors	=> 'Marco d\'Itri',
	contact	=> 'md@linux.it',
	name	=> 'ro_challenge',
	description	=> 'Challenge-response authentication for IRC operators of ratbox 2.2-based servers.',
	license	=> 'GPL',
	url		=> 'https://www.linux.it/~md/irssi/',
	changed	=> '2025-02-04'
);

##############################################################################
my ($Challenge_Text, $Challenge_Passphrase);

# Settings
Irssi::settings_add_bool('ro', 'ro_challenge_debug' => 0);
Irssi::settings_add_str('ro', 'ro_challenge_keyfile' => '');
Irssi::settings_add_str('ro', 'ro_challenge_respond' => '');
Irssi::settings_add_str('ro', 'ro_challenge_pass_program' => '');

# Signals
Irssi::signal_add('event 740' => \&event_challenge_rpl);
Irssi::signal_add('event 741' => \&event_challenge_rpl_end);

# Commands
Irssi::command_bind('challenge' => \&cmd_challenge);
Irssi::command_bind('help' => \&cmd_help);

##############################################################################
sub default_passphrase ($network, $nick) {
	my $program = Irssi::settings_get_str('ro_challenge_pass_program')
		or return;
	$program =~ s/%c/$network/eg;
	$program =~ s/%n/$nick/eg;

	my $passphrase = qx{$program};
	chomp $passphrase;
	if ($passphrase eq '') {
		Irssi::print("challenge: '$program' returned no output!",
			MSGLEVEL_CLIENTERROR);
		return;
	}

	return $passphrase;
}

##############################################################################
sub cmd_challenge ($cmdline, $server, $channel) {
	if (not $server or not $server->{connected}) {
		Irssi::print('challenge: not connected to a server!',
			MSGLEVEL_CLIENTERROR);
		return;
	}

	$cmdline =~ s/\s*$//;
	my ($opernick, $passphrase) = split(/\s+/, $cmdline, 2);
	$opernick //= $server->{nick};
	$passphrase //= default_passphrase($server->{chatnet}, $server->{nick});

	my $debug = Irssi::settings_get_bool('ro_challenge_debug');
	if ($debug) {
		Irssi::print("challenge: opernick = $opernick");
		if ($passphrase eq '') {
			Irssi::print('challenge: passphrase = <empty>');
		} else {
			Irssi::print('challenge: passphrase = <hidden>');
		}
	}

	$Challenge_Text = '';
	$Challenge_Passphrase = $passphrase;

	Irssi::print("challenge: sending CHALLENGE $opernick") if $debug;
	$server->send_raw("CHALLENGE $opernick");

	return;
}

##############################################################################
# Handle incoming 740 numeric (receive one or more parts
# of the challenge text from server)
sub event_challenge_rpl ($server, $data, $, $) {
	my $debug = Irssi::settings_get_bool('ro_challenge_debug');
	Irssi::print("challenge: received text --> $data") if $debug;

	$data =~ /^\S+ :(.+)/;
	$Challenge_Text .= $1;

	return;
}

# Handle incoming 741 numeric - server has sent us all
# challenge text, and we should generate and send our reply
sub event_challenge_rpl_end ($server, $, $, $) {
	my $debug = Irssi::settings_get_bool('ro_challenge_debug');
	Irssi::print("challenge: received the complete challenge text'
		. ' --> $Challenge_Text") if $debug;

	send_response($server);
	return;
}

##############################################################################
sub send_response ($server) {
	my $keyfile_path = Irssi::settings_get_str('ro_challenge_keyfile');
	if (not $keyfile_path) {
		Irssi::print('challenge: whoops! You need to /set'
			. ' ro_challenge_keyfile to the path of the private key file!',
			MSGLEVEL_CLIENTERROR);
		return;
	}

	$keyfile_path = path($keyfile_path);
	if (not -r $keyfile_path) {
		Irssi::print("challenge: $keyfile_path is missing or not readable!",
			MSGLEVEL_CLIENTERROR);
		return;
	}

	my $key_string = $keyfile_path->slurp;
	if (not defined $Challenge_Passphrase
			and $key_string =~ /ENCRYPTED PRIVATE KEY/) {
		Irssi::print('challenge: cannot respond to the challenge without'
			. ' the private key passphrase!', MSGLEVEL_CLIENTERROR);
		return;
	}

	my $response = eval {
		ratbox_respond($Challenge_Text, $key_string, $Challenge_Passphrase);
	};
	$Challenge_Text = undef;
	$Challenge_Passphrase = undef;

	if ($@) {
		$@ =~ s/\n//g;
		Irssi::print("challenge: could not compute the response:\n  $@",
			MSGLEVEL_CLIENTERROR);
		return;
	}

	my $debug = Irssi::settings_get_bool('ro_challenge_debug');
	Irssi::print("challenge: sending response <-- $response") if $debug;

	$server->send_raw("CHALLENGE +$response");

	return;
}

sub ratbox_respond ($challenge, $key, $passphrase) {
	$challenge = decode_base64($challenge);
	my $rsa = Crypt::OpenSSL::RSA->new_private_key($key, $passphrase);
	my $plaintext = $rsa->decrypt($challenge);
	my $response = sha1($plaintext);
	return encode_base64($response, '');
}

##############################################################################
sub cmd_help ($cmdline, $server, $channel) {
	return if not $cmdline or $cmdline !~ /^\s*challenge\s*/i;

	my $help = << 'END';

%9Syntax:

CHALLENGE [<opernick> [<passphrase>]]

%9Parameters:

    <opernick>   - the nick listed in the O line
    <passphrase> - the passphrase for the RSA key
END
	Irssi::print($help, MSGLEVEL_CLIENTCRAP);
	Irssi::signal_stop;
	return;
}

