diff --git a/README.md b/README.md
index a8d7057..5534c43 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ Features:
* Clean the whole room
* Plain text message filter
* Regex message filter
+* 2FA via PGP
* And more
Installation Instructions:
diff --git a/chat.php b/chat.php
index 43c6093..10f7903 100644
--- a/chat.php
+++ b/chat.php
@@ -122,6 +122,10 @@ function route(): void
check_login();
show_fails();
send_frameset();
+ }elseif($_REQUEST['action']==='pgp2fa'){
+ check_pgp2fa();
+ show_fails();
+ send_frameset();
}elseif($_REQUEST['action']==='controls'){
check_session();
send_controls();
@@ -2419,6 +2423,14 @@ function send_profile(string $arg=''): void
while($tmp=$stmt->fetch(PDO::FETCH_ASSOC)){
$ignored[]=htmlspecialchars($tmp['ign']);
}
+ $pgpkey='';
+ if($U['status']>=2){
+ $stmt=$db->prepare('SELECT pgpkey FROM ' . PREFIX . 'members WHERE nickname=?;');
+ $stmt->execute([$U['nickname']]);
+ if($tmp=$stmt->fetch(PDO::FETCH_NUM)){
+ $pgpkey=$tmp[0] ?? '';
+ }
+ }
if(count($ignored)>0){
echo '
|
';
thr();
+ echo ' |
';
+ thr();
echo ' |
';
@@ -2668,6 +2685,22 @@ function send_colours(): void
print_end();
}
+function send_pgp2fa(string $nickname, string $encrypted_challenge): void
+{
+ print_start('pgp2fa');
+ echo ''.get_setting('chatname').'
';
+ echo ''._('PGP two-factor authentication').'
';
+ echo ''._('Decrypt this message with your PGP private key, then enter the verification code.').'
';
+ echo '';
+ echo form_target('_parent', 'pgp2fa');
+ echo hidden('nick', htmlspecialchars($nickname));
+ echo '';
+ echo form_target('_parent', '').submit(_('Back to the login page.'), 'class="backbutton"').'';
+ print_end();
+}
+
function send_login(): void
{
$ga=(int) get_setting('guestaccess');
@@ -2836,6 +2869,9 @@ function create_session(bool $setup, string $nickname, string $password): void
if($setup && $U['status']>=7){
$U['incognito']=1;
}
+ if(!$setup && !empty(trim($U['pgpkey'] ?? ''))){
+ start_pgp2fa();
+ }
$U['entry']=$U['lastpost']=time();
}else{
add_user_defaults($password);
@@ -2865,6 +2901,189 @@ function create_session(bool $setup, string $nickname, string $password): void
write_new_session($password);
}
+function pgp_encrypt_message(string $public_key, string $message) : string
+{
+ try {
+ $encrypted=pgp_try_encrypt_message($public_key, $message);
+ } catch(Throwable $e) {
+ send_error($e->getMessage());
+ }
+ return $encrypted;
+}
+
+function pgp_try_encrypt_message(string $public_key, string $message) : string
+{
+ $public_key=str_replace("\r\n", "\n", trim($public_key));
+ if(extension_loaded('gnupg')){
+ $gpg=new gnupg();
+ $info=$gpg->import($public_key);
+ $fingerprint=pgp_import_fingerprint($info);
+ if($fingerprint!==''){
+ $gpg->addencryptkey($fingerprint);
+ $encrypted=$gpg->encrypt($message);
+ if($encrypted!==false){
+ return $encrypted;
+ }
+ }
+ }
+ return pgp_try_encrypt_message_with_gpg($public_key, $message);
+}
+
+function pgp_import_fingerprint($info) : string
+{
+ if(!is_array($info)){
+ return '';
+ }
+ if(!empty($info['fingerprint']) && is_string($info['fingerprint'])){
+ return $info['fingerprint'];
+ }
+ if(!empty($info['fingerprints']) && is_array($info['fingerprints'])){
+ foreach($info['fingerprints'] as $fingerprint){
+ if(is_string($fingerprint) && $fingerprint!==''){
+ return $fingerprint;
+ }
+ }
+ }
+ if(!empty($info[0]) && is_string($info[0])){
+ return $info[0];
+ }
+ return '';
+}
+
+function pgp_try_encrypt_message_with_gpg(string $public_key, string $message) : string
+{
+ if(!function_exists('exec')){
+ throw new RuntimeException(_('The PHP exec function or the gnupg extension is required for PGP two-factor authentication.'));
+ }
+ $gpg=pgp_find_gpg_binary();
+ $base=rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'lechatpgp-'.bin2hex(random_bytes(8));
+ if(!mkdir($base, 0700)){
+ throw new RuntimeException(_('Could not create a temporary directory for PGP two-factor authentication.'));
+ }
+ $keyfile=$base.DIRECTORY_SEPARATOR.'key.asc';
+ $messagefile=$base.DIRECTORY_SEPARATOR.'message.txt';
+ $outputfile=$base.DIRECTORY_SEPARATOR.'message.asc';
+ file_put_contents($keyfile, $public_key);
+ file_put_contents($messagefile, $message);
+ try {
+ $result=pgp_run_command([$gpg, '--batch', '--homedir', $base, '--import', $keyfile]);
+ if($result['code']!==0){
+ throw new RuntimeException(_('Invalid PGP public key.').' '.htmlspecialchars($result['output']));
+ }
+ $result=pgp_run_command([$gpg, '--batch', '--homedir', $base, '--with-colons', '--fingerprint', '--list-keys']);
+ $fingerprint='';
+ foreach(explode("\n", $result['output']) as $line){
+ if(preg_match('/^fpr:::::::::([0-9A-F]+):/i', $line, $match)){
+ $fingerprint=$match[1];
+ break;
+ }
+ }
+ if($fingerprint===''){
+ throw new RuntimeException(_('Invalid PGP public key.'));
+ }
+ $result=pgp_run_command([$gpg, '--batch', '--yes', '--trust-model', 'always', '--homedir', $base, '--armor', '--encrypt', '--recipient', $fingerprint, '--output', $outputfile, $messagefile]);
+ if($result['code']!==0 || !is_file($outputfile)){
+ throw new RuntimeException(_('Could not encrypt the PGP two-factor authentication challenge.').' '.htmlspecialchars($result['output']));
+ }
+ return file_get_contents($outputfile);
+ } finally {
+ pgp_remove_directory($base);
+ }
+}
+
+function pgp_find_gpg_binary() : string
+{
+ if(defined('GPG_BINARY')){
+ return GPG_BINARY;
+ }
+ foreach(['gpg', '/usr/bin/gpg', '/usr/local/bin/gpg', '/opt/homebrew/bin/gpg'] as $gpg){
+ $result=pgp_run_command([$gpg, '--version']);
+ if($result['code']===0){
+ return $gpg;
+ }
+ }
+ throw new RuntimeException(_('The gpg command or the gnupg extension is required for PGP two-factor authentication.'));
+}
+
+function pgp_run_command(array $args) : array
+{
+ $cmd=implode(' ', array_map('escapeshellarg', $args));
+ $output=[];
+ $code=0;
+ exec($cmd.' 2>&1', $output, $code);
+ return ['code' => $code, 'output' => implode("\n", $output)];
+}
+
+function pgp_remove_directory(string $dir): void
+{
+ if(!is_dir($dir)){
+ return;
+ }
+ foreach(scandir($dir) as $file){
+ if($file==='.' || $file==='..'){
+ continue;
+ }
+ $path=$dir.DIRECTORY_SEPARATOR.$file;
+ if(is_dir($path)){
+ pgp_remove_directory($path);
+ }else{
+ unlink($path);
+ }
+ }
+ rmdir($dir);
+}
+
+function start_pgp2fa(): void
+{
+ global $U, $db;
+ try {
+ $code=strtoupper(bin2hex(random_bytes(4)));
+ } catch(Exception $e) {
+ send_error($e->getMessage());
+ }
+ $message=sprintf(_("Your verification code for %s is: %s"), get_setting('chatname'), $code);
+ $encrypted=pgp_encrypt_message($U['pgpkey'], $message);
+ $stmt=$db->prepare('UPDATE ' . PREFIX . 'members SET pgpchallengehash=?, pgpchallengeexpires=? WHERE nickname=?;');
+ $stmt->execute([hash('sha256', $code), time()+300, $U['nickname']]);
+ send_pgp2fa($U['nickname'], $encrypted);
+}
+
+function check_pgp2fa(): void
+{
+ global $U, $db;
+ if(empty($_POST['nick']) || empty($_POST['pgpcode'])){
+ send_login();
+ }
+ $nick=preg_replace('/\s/', '', $_POST['nick']);
+ $stmt=$db->prepare('SELECT * FROM ' . PREFIX . 'members WHERE nickname=?;');
+ $stmt->execute([$nick]);
+ if(!$member=$stmt->fetch(PDO::FETCH_ASSOC)){
+ send_error(_('Invalid PGP two-factor authentication challenge.'));
+ }
+ $hash=$member['pgpchallengehash'] ?? '';
+ $expires=(int) ($member['pgpchallengeexpires'] ?? 0);
+ $code=strtoupper(preg_replace('/[^0-9A-F]/i', '', $_POST['pgpcode']));
+ if($hash==='' || $expires