From 20908f788843b160948ba4b6420cbdad5aca025b Mon Sep 17 00:00:00 2001
From: Matthieu Marcillaud <marcimat@rezo.net>
Date: Fri, 25 Feb 2022 18:11:10 +0100
Subject: [PATCH] =?UTF-8?q?S=C3=A9curit=C3=A9=20des=20authentifications=20?=
 =?UTF-8?q?avec=20le=20secret=20du=20site=20(g0uZ)=20:?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- la clé secret_du_site est partagée entre le disque et la base de données. Il faut les 2 pour le calculer (la clé secret_du_site, et la meta secret_du_site).
- Si on demande à chiffrer avec autre chose qu’une clé de longueur adaptée (ie: générée par Chiffrement::keygen()), tel qu’un mot de passe, alors on passe dans sodium_crypto_pwhash(),
qui est la fonction adaptée à ce cas, mais le coût est très élevé dans ce cas (temps / mémoire, même au minimum possible)
- On s’arrange donc pour que le secret_du_site obtenu soit effectivement de la taille adaptée à la clé de chiffrement.
---
 ecrire/action/inscrire_auteur.php   | 17 ++++---
 ecrire/inc/securiser_action.php     | 14 +++---
 ecrire/src/Chiffrer/Chiffrement.php | 73 ++++++++++++++---------------
 ecrire/src/Chiffrer/SpipCles.php    | 45 +++++++++++++++++-
 4 files changed, 95 insertions(+), 54 deletions(-)

diff --git a/ecrire/action/inscrire_auteur.php b/ecrire/action/inscrire_auteur.php
index 3bda68d705..59ab85d3f4 100644
--- a/ecrire/action/inscrire_auteur.php
+++ b/ecrire/action/inscrire_auteur.php
@@ -15,6 +15,11 @@
  *
  * @package SPIP\Core\Inscription
  **/
+
+use Spip\Chiffrer\Chiffrement;
+use Spip\Chiffrer\SpipCles;
+
+
 if (!defined('_ECRIRE_INC_VERSION')) {
 	return;
 }
@@ -340,8 +345,7 @@ function confirmer_statut_inscription($auteur) {
 
 
 /**
- * Attribuer un jeton temporaire pour un auteur
- * en assurant l'unicite du jeton.
+ * Attribuer un jeton temporaire pour un auteur en assurant l'unicite du jeton.
  * 
  * Chaque appel crée un nouveau jeton pour l’auteur
  * et invalide donc le précédent
@@ -359,7 +363,7 @@ function auteur_attribuer_jeton($id_auteur): string {
 		// tous les jetons connus pour vérifier le jeton d’un auteur. 
 		$public = substr(creer_uniqid(), 0, 7) . '.';
 		$jeton = $public . creer_uniqid();
-		$jeton_chiffre_prefixe = $public . \Spip\Chiffrer\Chiffrement::chiffrer($jeton);
+		$jeton_chiffre_prefixe = $public . Chiffrement::chiffrer($jeton, SpipCles::secret_du_site());
 		sql_updateq('spip_auteurs', ['cookie_oubli' => $jeton_chiffre_prefixe], 'id_auteur=' . intval($id_auteur));
 	} while (sql_countsel('spip_auteurs', 'cookie_oubli=' . sql_quote($jeton_chiffre_prefixe, '', 'string')) > 1);
 
@@ -367,7 +371,7 @@ function auteur_attribuer_jeton($id_auteur): string {
 }
 
 /**
- * Lire un jeton temporaire d’un auteur
+ * Lire un jeton temporaire d’un auteur (peut le créer au besoin)
  * 
  * Cette fonction peut être pratique si plusieurs notifications proches
  * dans la durée sont envoyées au même auteur.
@@ -382,7 +386,7 @@ function auteur_lire_jeton(int $id_auteur, bool $autoInit = false): ?string {
 	if ($jeton_chiffre_prefixe) {
 		include_spip('inc/chiffrer');
 		$jeton_chiffre = substr($jeton_chiffre_prefixe, 8);
-		$jeton = \Spip\Chiffrer\Chiffrement::dechiffrer($jeton_chiffre);
+		$jeton = Chiffrement::dechiffrer($jeton_chiffre, SpipCles::secret_du_site());
 		if ($jeton) {
 			return $jeton;
 		}
@@ -409,10 +413,11 @@ function auteur_verifier_jeton($jeton) {
 	include_spip('inc/chiffrer');
 	$public = substr($jeton, 0, 8);
 
+	// Les auteurs qui ont un jetons ressemblant
 	$auteurs = sql_allfetsel('*', 'spip_auteurs', 'cookie_oubli LIKE ' . sql_quote($public . '%'));
 	foreach ($auteurs as $auteur) {
 		$jeton_chiffre = substr($auteur['cookie_oubli'], 8);
-		$_jeton = \Spip\Chiffrer\Chiffrement::dechiffrer($jeton_chiffre);
+		$_jeton = Chiffrement::dechiffrer($jeton_chiffre, SpipCles::secret_du_site());
 		if ($_jeton and hash_equals($jeton, $_jeton)) {
 			return $auteur;
 		}
diff --git a/ecrire/inc/securiser_action.php b/ecrire/inc/securiser_action.php
index 31fbe57ee7..970c5b1f0b 100644
--- a/ecrire/inc/securiser_action.php
+++ b/ecrire/inc/securiser_action.php
@@ -16,6 +16,8 @@
  * @package SPIP\Core\Actions
  **/
 
+ use \Spip\Chiffrer\SpipCles;
+
 if (!defined('_ECRIRE_INC_VERSION')) {
 	return;
 }
@@ -293,18 +295,14 @@ function verifier_action_auteur($action, $hash) {
 //
 
 /**
- * Renvoyer le secret du site, et le generer si il n'existe pas encore
- * Le secret du site doit rester aussi secret que possible, et est eternel
- * On ne doit pas l'exporter
- *
+ * Renvoyer le secret du site (le generer si il n'existe pas encore)
+ * 
+ * @uses SpipCles::secret_du_site()
  * @return string
  */
 function secret_du_site() {
 	include_spip('inc/chiffrer');
-	$cles = \Spip\Chiffrer\SpipCles::instance();
-	$secret = $cles->getSecretSite();
-
-	return $secret;
+	return SpipCles::secret_du_site();
 }
 
 /**
diff --git a/ecrire/src/Chiffrer/Chiffrement.php b/ecrire/src/Chiffrer/Chiffrement.php
index c9be4fab60..3f2e329f3a 100644
--- a/ecrire/src/Chiffrer/Chiffrement.php
+++ b/ecrire/src/Chiffrer/Chiffrement.php
@@ -20,57 +20,48 @@ namespace Spip\Chiffrer;
  */
 class Chiffrement {
 
-	/** Chiffre un message en utilisant une clé (le secret_du_site par défaut) ou un mot de passe */
+	/** Chiffre un message en utilisant une clé ou un mot de passe */
 	public static function chiffrer(
 		string $message,
 		#[\SensitiveParameter]
-		?string $key = null
+		string $key
 	): ?string {
-		$key ??= self::getDefaultKey();
-		if (!$key) {
-			spip_log("chiffrer() sans clé `$message`", 'chiffrer' . _LOG_INFO_IMPORTANTE);
-			return null;
-		}
-		if (strlen($key) !== \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
-			$key = self::generateKeyFromPassword($key);
-		}
+		// create a random salt for key derivation
+		$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
+		$key = self::deriveKeyFromPassword($key, $salt);
 		$nonce = random_bytes(\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
 		$padded_message = sodium_pad($message, 16);
 		$encrypted = sodium_crypto_secretbox($padded_message, $nonce, $key);
-		$encoded = base64_encode($nonce . $encrypted);
+		$encoded = base64_encode($salt . $nonce . $encrypted);
 		sodium_memzero($key);
 		sodium_memzero($nonce);
+		sodium_memzero($salt);
 		spip_log("chiffrer($message)=$encoded", 'chiffrer' . _LOG_DEBUG);
 		return $encoded;
 	}
 
-	/** Déchiffre un message en utilisant une clé (le secret_du_site par défaut) ou un mot de passe */	
+	/** Déchiffre un message en utilisant une clé ou un mot de passe */	
 	public static function dechiffrer(
 		string $encoded,
 		#[\SensitiveParameter]
-		?string $key = null
+		string $key
 	): ?string {
-		$key ??= self::getDefaultKey();
-		if (!$key) {
-			spip_log("dechiffrer() sans clé `$encoded`", 'chiffrer' . _LOG_INFO_IMPORTANTE);
-			return null;
-		}
-		if (strlen($key) !== \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
-			$key = self::generateKeyFromPassword($key);
-		}
 		$decoded = base64_decode($encoded);
-		$nonce = mb_substr($decoded, 0, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
-		$encrypted_result = mb_substr($decoded, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
-		$padded_message = sodium_crypto_secretbox_open($encrypted_result, $nonce, $key);
-		$message = sodium_unpad($padded_message, 16);
+		$salt = substr($decoded, 0, \SODIUM_CRYPTO_PWHASH_SALTBYTES);
+        $nonce = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES, \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+        $encrypted = substr($decoded, \SODIUM_CRYPTO_PWHASH_SALTBYTES + \SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
+        $key = self::deriveKeyFromPassword($key, $salt);
+		$padded_message = sodium_crypto_secretbox_open($encrypted, $nonce, $key);
 		sodium_memzero($key);
 		sodium_memzero($nonce);
-		if ($message !== false) {
-			spip_log("dechiffrer($encoded)=$message", 'chiffrer' . _LOG_DEBUG);
-			return $message;
+		sodium_memzero($salt);
+		if ($padded_message === false) {
+			spip_log("dechiffrer() chiffre corrompu `$encoded`", 'chiffrer' . _LOG_DEBUG);
+			return null;
 		}
-		spip_log("dechiffrer() chiffre corrompu `$encoded`", 'chiffrer' . _LOG_DEBUG);
-		return null;
+		$message = sodium_unpad($padded_message, 16);
+		spip_log("dechiffrer($encoded)=$message", 'chiffrer' . _LOG_DEBUG);
+		return $message;
 	}
 
 	/** Génère une clé de la taille attendue pour le chiffrement */
@@ -84,17 +75,23 @@ class Chiffrement {
 	 * Notamment si on utilise un mot de passe comme clé, il faut le hacher
 	 * pour servir de clé à la taille correspondante.
 	 */
-	private static function generateKeyFromPassword(string $password): string {
+	private static function deriveKeyFromPassword(
+		#[\SensitiveParameter]
+		string $password,
+		string $salt
+	): string {
 		if (strlen($password) === \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
 			return $password;
 		}
-		$hashed = hash('sha256', $password);
-		return substr($hashed, 0, \SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
-	}
+        $key = sodium_crypto_pwhash(
+            \SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
+            $password,
+            $salt,
+            \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
+            \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
+        );
+        sodium_memzero($password);
 
-	/** Retourne la clé de chiffrement par défaut (le secret_du_site) */
-	private static function getDefaultKey(): ?string {
-		$keys = SpipCles::instance();
-		return $keys->getSecretSite();
+        return $key;
 	}
 }
diff --git a/ecrire/src/Chiffrer/SpipCles.php b/ecrire/src/Chiffrer/SpipCles.php
index 10ae2a8cbc..1071e13287 100644
--- a/ecrire/src/Chiffrer/SpipCles.php
+++ b/ecrire/src/Chiffrer/SpipCles.php
@@ -26,6 +26,14 @@ final class SpipCles {
 		return self::$instances[$file];
 	}
 
+	/**
+	 * Retourne le secret du site (shorthand)
+	 * @uses self::getSecretSite()
+	 */
+	public static function secret_du_site(): ?string {
+		return (self::instance())->getSecretSite();
+	}
+
 	private function __construct(string $file = '') {
 		if ($file) {
 			$this->file = $file;
@@ -33,13 +41,27 @@ final class SpipCles {
 		$this->cles = new Cles($this->read());
 	}
 
+	/**
+	 * Renvoyer le secret du site
+	 *
+	 * Le secret du site doit rester aussi secret que possible, et est eternel
+	 * On ne doit pas l'exporter
+	 *
+	 * Le secret est partagé entre une clé disque et une clé bdd
+	 *
+	 * @return string
+	 */
 	public function getSecretSite(bool $autoInit = true): ?string {
-		return $this->getKey('secret_du_site', $autoInit);
+		$key = $this->getKey('secret_du_site', $autoInit);
+		$meta = $this->getMetaKey('secret_du_site', $autoInit);
+		// conserve la même longeur.
+		return $key ^ $meta;
 	}
+
+	/** Renvoyer le secret des authentifications */	
 	public function getSecretAuth(bool $autoInit = false): ?string {
 		return $this->getKey('secret_des_auth', $autoInit);
 	}
-
 	public function save(): bool {
 		return ecrire_fichier_securise($this->file, $this->cles->toJson());
 	}
@@ -124,6 +146,25 @@ final class SpipCles {
 		return null;
 	}
 
+	private function getMetaKey(string $name, bool $autoInit = true): ?string {
+		if (!isset($GLOBALS['meta'][$name])) {
+			include_spip('base/abstract_sql');
+			$GLOBALS['meta'][$name] = sql_getfetsel('valeur', 'spip_meta', 'nom = ' . sql_quote($name, '', 'string'));
+		}
+		$key = base64_decode($GLOBALS['meta'][$name] ?? '');
+		if (strlen($key) === \SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
+			return $key;
+		}
+		if (!$autoInit) {
+			return null;
+		}
+		$key = Chiffrement::keygen();
+		ecrire_meta($name, base64_encode($key), 'non');
+		lire_metas(); // au cas ou ecrire_meta() ne fonctionne pas
+
+		return $key;
+	}
+
 	private function read(): array {
 		lire_fichier_securise($this->file, $json);
 		if (
-- 
GitLab