Le symptôme ressemble à tout sauf à un problème de chiffrement. Un module qui poussait des données vers un service tiers ne pousse plus rien. Le hook est bien enregistré, il s’exécute, les statuts déclencheurs sont corrects, l’interface du module s’affiche normalement et continue même d’afficher les données déjà collectées. Aucune exception, aucune ligne dans les logs. Un échec parfaitement muet, comme il en existe quelques-uns dans PrestaShop. Le client remonte simplement que le prestataire ne voit plus ses commandes.
La cause remontait à une semaine plus tôt, dans une opération qui n’avait rien à voir : la reprise d’une cookie_key lors d’une migration.
Cet article raconte ce que fait vraiment cette clé dans PrestaShop, pourquoi elle relie trois mécanismes sans rapport apparent, et pourquoi une migration depuis une version ancienne vous place devant un choix dont personne ne parle.
Il y a deux clés, pas une
Premier malentendu à lever : parameters.php contient deux clés de nature différente, et on les confond en permanence parce qu’elles se ressemblent et vivent au même endroit.
// config/bootstrap.php
define('_COOKIE_KEY_', $config['parameters']['cookie_key']);
// ...
// New cookie
if (array_key_exists('new_cookie_key', $config['parameters'])) {
define('_NEW_COOKIE_KEY_', $config['parameters']['new_cookie_key']);
} else {
$key = PhpEncryption::createNewRandomKey();
define('_NEW_COOKIE_KEY_', $key);
}
_NEW_COOKIE_KEY_ est une clé au format Defuse\Crypto. C’est elle, et elle seule, qui chiffre les cookies :
// classes/Cookie.php
$this->cipherTool = new PhpEncryption(_NEW_COOKIE_KEY_);
_COOKIE_KEY_, la clé historique de 56 caractères générée par Tools::passwdGen(56), ne sert plus du tout aux cookies. Elle ne subsiste que pour deux usages, et ce sont précisément les deux qui vont nous occuper : la vérification des mots de passe au format historique, et le chiffrement que des modules tiers mettent en place pour leurs propres secrets.
Retenez ce point, c’est la clé de tout ce qui suit : le nom de la constante ne décrit plus son usage. On croit toucher à des cookies, on touche à des mots de passe et à des clés d’API.
Non, PrestaShop 9 n’a pas changé le chiffrement des mots de passe
C’est la croyance la plus répandue sur le sujet, et nous l’avons eue aussi. Vérification faite directement sur les tags officiels du dépôt PrestaShop :
| 1.7.8.11 | 8.1.7 | 9.0.0 | |
|---|---|---|---|
Hashing : méthodes bcrypt puis md5 |
oui | oui | oui |
isFirstHash() |
oui | oui | oui |
_NEW_COOKIE_KEY_ et PhpEncryption dans Cookie.php |
oui | oui | oui |
Rien n’a bougé entre la 1.7 et la 9. Ni le hachage des mots de passe, ni le chiffrement des cookies.
Ce qui explique l’impression contraire, c’est l’écart de version. Quand on migre une boutique 1.6 directement vers une 9, on encaisse d’un seul coup tout ce qui a changé en 1.7, en 8 et en 9. Vu depuis cette migration, ça ressemble à une nouveauté de la 9, alors que c’est un changement de la 1.7 qu’on rencontre avec plusieurs années de retard. C’est d’ailleurs l’un des arguments en faveur des migrations par paliers plutôt que d’un grand saut.
La distinction n’est pas cosmétique. Elle détermine où chercher la documentation, et elle explique pourquoi les rares discussions sur le sujet parlent toutes de la 1.7.
Comment PrestaShop gère deux formats de hash en même temps
Le mécanisme tient dans une seule classe, PrestaShop\PrestaShop\Core\Crypto\Hashing, et dans l’ordre de son tableau de méthodes :
private function initHashMethods()
{
$this->hashMethods = [
'bcrypt' => [
'hash' => function ($passwd, $staticSalt, $option) {
return password_hash($passwd, PASSWORD_BCRYPT);
},
'verify' => function ($passwd, $hash, $staticSalt) {
// ...
return password_verify($passwd, $hash);
},
],
'md5' => [
'hash' => function ($passwd, $staticSalt, $option) {
return md5($staticSalt . $passwd);
},
'verify' => function ($passwd, $hash, $staticSalt) {
return md5($staticSalt . $passwd) === $hash;
},
],
];
}
Deux méthodes, dans cet ordre. checkHash() les essaie l’une après l’autre jusqu’à en trouver une qui valide, ce qui assure la rétrocompatibilité avec les mots de passe historiques. isFirstHash(), lui, répond à une question différente : ce hash a-t-il été produit par la méthode courante, c’est-à-dire la première du tableau ?
Notez la différence de traitement du sel. La méthode md5 concatène $staticSalt, dont la valeur par défaut est _COOKIE_KEY_. La méthode bcrypt, elle, ignore complètement ce paramètre : password_hash() génère son propre sel et l’embarque dans le hash produit.
C’est de cette asymétrie que découle tout le reste.
La réécriture silencieuse à la connexion
Voici le mécanisme que peu de développeurs PrestaShop connaissent, dans classes/Customer.php :
if ($shouldCheckPassword && !$crypto->isFirstHash($plaintextPassword, $passwordHash)) {
$this->passwd = $crypto->hash($plaintextPassword);
$this->update();
}
À chaque connexion réussie, si le hash stocké n’a pas été produit par la méthode courante, PrestaShop le remplace par un hash bcrypt et l’écrit en base. Le client ne voit rien, ne fait rien, et son compte vient de changer de format.
C’est élégant : la migration des mots de passe se fait toute seule, au fil de l’eau, sans jamais demander à personne de réinitialiser quoi que ce soit.
Sauf pour le back-office, qui ne suit pas la même route
Le même bloc de réécriture existe dans classes/Employee.php, et Employee::getByEmail() appelle lui aussi checkHash(). On en déduit naturellement que les comptes du back-office bénéficient du même repêchage.
C’est faux sur PrestaShop 9, et la nuance peut coûter une migration.
La connexion au back-office ne passe plus par ce chemin. Elle est prise en charge par le pare-feu Symfony, configuré dans app/config/admin/security.yml :
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: 'auto'
cost: 15
L’algorithme auto de Symfony sélectionne bcrypt ou argon selon l’environnement. Il ne connaît strictement rien au md5(_COOKIE_KEY_ . $password) de PrestaShop, et n’a aucune raison de l’essayer.
Conséquence concrète : un compte employé encore au format historique ne peut pas se connecter au back-office d’une PrestaShop 9, même avec la bonne cookie_key. Un client en front-office, lui, le peut toujours. Les deux populations ne migrent donc pas au même rythme, et l’une d’elles ne migre pas du tout.
Sur une reprise depuis une 1.6, il faut re-hasher les mots de passe employés en bcrypt :
php -r "echo password_hash('votre-mot-de-passe', PASSWORD_BCRYPT);"
Le problème ne se pose en revanche pas sur une migration depuis une 1.7 ou une 8, où les comptes du back-office sont déjà en bcrypt depuis longtemps.
Sauf que cette migration ne se termine jamais
Assemblons les deux morceaux.
Un compte encore au format historique n’est vérifiable qu’avec md5(_COOKIE_KEY_ . $password). Si la clé n’est pas celle de l’installation d’origine, la vérification échoue. Le client saisit le bon mot de passe et se voit refuser l’accès.
Et comme c’est la connexion réussie qui déclenche la réécriture, ce compte ne migrera jamais vers bcrypt. Il reste indéfiniment prisonnier d’une clé qu’on n’a plus.
D’où la règle bien connue lors d’une migration : reprendre la cookie_key de l’ancienne installation, sinon plus personne ne se connecte. Jusque là, c’est documenté un peu partout, toujours sous l’angle des mots de passe clients.
Ce qui l’est beaucoup moins, c’est la suite. Une fois la clé legacy en place, chaque première connexion réussie affranchit définitivement un compte : passé en bcrypt, il ne dépend plus d’aucune clé statique. La population encore au format historique décroît donc à mesure que les clients reviennent.
Elle décroît, mais elle n’atteint jamais zéro. Il reste toujours les comptes dormants, ceux qui ne se sont pas connectés depuis deux ans et qui reviendront peut-être un jour. Tant qu’il en subsiste un seul, faire tourner la cookie_key revient à le condamner sans le savoir.
Vous héritez donc d’une clé que vous ne pouvez plus changer, dont le nom ne décrit pas l’usage, et dont plus personne dans l’équipe ne sait exactement à quoi elle sert trois ans plus tard.
Et le cookie_iv dans tout ça ?
La question revient systématiquement dès qu’on parle de reprise de clés : faut-il aussi emporter le cookie_iv ? Pour les mots de passe, non.
_COOKIE_IV_ sert de sel au cookie lui-même :
// classes/Cookie.php
$this->_salt = $this->_standalone ? str_pad('', 32, md5('ps' . __FILE__)) : _COOKIE_IV_;
Son seul autre usage dans le core est Tools::hashIV() :
public static function hashIV($data)
{
return (new Hashing())->hash($data, _COOKIE_IV_);
}
Or Hashing::hash() utilise la première méthode du tableau, bcrypt, qui ignore le sel qu’on lui passe. Le paramètre est donc inerte à cet endroit : le nom de la méthode promet un hash dérivé de l’IV, la valeur retournée est un bcrypt à sel aléatoire.
Reprendre le cookie_iv ne fait donc aucun mal, mais ne sauve aucun mot de passe. Ce qui compte pour l’authentification, c’est cookie_key, et elle seule. Le cookie_iv relève d’une autre question, celle de la continuité des sessions ouvertes.
La panne muette : les secrets de modules
C’est là que l’histoire du début prend son sens.
Beaucoup de modules qui dialoguent avec un service tiers stockent leur clé d’API dans ps_configuration, et prennent la peine de la chiffrer plutôt que de la laisser en clair. La bonne pratique évidente, quand on cherche un matériel de clé déjà disponible et propre à l’installation, c’est d’utiliser _COOKIE_KEY_.
Ce qui donne, à peu près partout, le même schéma :
// à la saisie en back-office
Configuration::updateValue('MONMODULE_PRIVATE_KEY', $this->encrypt($key));
// à l'usage
$key = $this->decrypt(Configuration::get('MONMODULE_PRIVATE_KEY'));
Tant que la clé ne bouge pas, tout va bien. Le jour où on la remplace par celle de l’ancienne installation pour sauver les connexions clients, toute valeur chiffrée avec l’ancienne devient indéchiffrable.
Et le déchiffrement ne lève en général pas d’exception. Il retourne une chaîne vide. Le module part alors s’authentifier auprès du service tiers avec une clé vide, se fait refuser, et selon la qualité de sa gestion d’erreur, n’en dit rien à personne.
Le symptôme est trompeur pour trois raisons :
Ce qui est en lecture continue de marcher. Si le module affiche des données publiques récupérées avec une clé publique stockée en clair, cette partie ne bouge pas. Visuellement, le module a l’air en parfaite santé.
Le hook s’exécute correctement. Vous pouvez le tracer, le déclencher à la main, il fait son travail. Rien n’indique que le problème est en amont, dans une valeur de configuration.
Le décalage temporel brouille la piste. La bascule de clé a eu lieu lors de la mise en production. Le module, lui, ne tombe en panne qu’à la première tentative d’envoi qui suit, parfois plusieurs jours plus tard. Personne ne fait le lien.
Le test de diagnostic
Devant tout module qui « ne communique plus » depuis une mise en production, le premier réflexe devrait être de vérifier si son secret est chiffré avec _COOKIE_KEY_. Le test se fait sans effet de bord :
$module = Module::getInstanceByName('monmodule');
$method = new ReflectionMethod($module, 'decryptPrivateKey');
$method->setAccessible(true);
var_dump($method->invoke($module));
Une chaîne vide en retour, alors que Configuration::get() renvoie bien une valeur non vide, et le diagnostic est posé.
Le correctif ne demande aucun déploiement : il suffit de ressaisir la clé dans le back-office du module, ce qui la re-chiffre avec la clé courante.
Un repère de reconnaissance utile pour situer une valeur dans le temps : la cookie_key au format historique fait 56 caractères, la new_cookie_key en fait beaucoup plus. Si une valeur a été chiffrée avant une bascule, elle l’a été avec la clé de l’époque.
Ce qu’on en retient
Cette clé est un point de passage qui relie trois mécanismes que rien ne rapproche à première vue : l’authentification des clients, une migration de format de hash qui progresse au rythme des connexions, et les secrets que des modules tiers chiffrent pour leur propre compte.
Trois conséquences pratiques.
Traitez la reprise de la cookie_key comme une décision d’architecture, pas comme une ligne de configuration à recopier. Elle vous engage pour des années.
Inventoriez ce qui est chiffré avec elle avant d’y toucher. Un simple parcours des modules qui appellent _COOKIE_KEY_ en dehors du contexte des mots de passe vous donne la liste des choses qui casseront, et elles casseront en silence.
Exigez un log sur les échecs de déchiffrement. C’est la vraie leçon de cette histoire. Le problème n’est pas que le déchiffrement ait échoué, c’est qu’il ait échoué sans le dire. Dans vos propres modules, un decrypt() qui retourne une chaîne vide alors que la valeur stockée n’est pas vide mérite systématiquement un log de niveau error, avec le nom de la configuration concernée.
C’est le même raisonnement que pour un produit publié en SQL brut, qui contourne les hooks sans que rien ne proteste. Une panne qui parle se répare en dix minutes. Une panne muette se répare quand quelqu’un finit par la remarquer.

