PHP et la cryptographie

Dans cet article, je vais vous faire part d’un retour d’expérience sur une situation que j’ai vécue au travail, il y a quelques jours seulement, en binôme avec Matthieu ANCERET.

Il sera question de PHP, de cryptographie AES-256-CBC, de C# et de WTF

Mais WTF?

C’est parti !

Nous intervenons sur un système pour lequel nous devons créer une API spécifique, en .Net Core, qui va devoir effectuer ses propres actions métiers, puis effectuer des insertions dans une base de données MySQL déjà utilisée par un site en PHP.
De son côté, le site PHP pourra lire et afficher les données insérées par notre API, afin de satisfaire son propre usage.

Point important à savoir : Les données étant sensibles (données personnelles), elles sont stockées de manière sécurisée en base de données, via un chiffrement géré par le site PHP.

Note : Le contexte client décrit ici est simplifié, voire détourné, pour plus facilement décrire la problématique à résoudre.

Nous avons donc :

  • D’un côté un site en PHP qui lit et écrit des données chiffrées dans une base de données MySQL,
  • Et de l’autre, une API en .Net Core chargée uniquement d’écrire des données dans cette base de données, avec le même chiffrement.
Schéma des flux

Qu’est-ce qui pourrait aller de travers ? 😈

Si l’on décompose de manière logique le problème exposé précédemment, il faut d’abord répondre à ces questions pour que notre API puisse fonctionner :

  • Quelle méthode de chiffrement est utilisée sur les données par le site PHP ?
  • Comment accéder aux clés de chiffrement, utilisées depuis le site PHP, depuis notre API ?
  • Comment accéder à la base de données MySQL depuis notre API ?

Le dernier point étant un sujet d’infrastructure, nous ne nous attarderons pas sur celui-ci dans cet article.

En revanche, nous allons nous concentrer sur les 2 autres, à savoir, « Quel est l’algorithme de chiffrement ? » et « Comment accéder aux clés de chiffrement ? ».

En réponse à ces questions, les développeurs du site PHP ont eu l’immense amabilité de nous transmettre le code source de la méthode de chiffrement utilisée dans le site :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
function encrypt_decrypt($action, $string)
{
    // Initialisation d'une valeur de retour par défaut
    $output = false;

    // Méthode de chiffrement des données
    $encrypt_method = "AES-256-CBC";
    // Clé de chiffrement
    $secret_key = "S4a5Qy@pPv2Di2jz^*WMrxZM6@X5za/.ss24x";
    // Vecteur d'initialisation
    $secret_iv = "i6qwQgZS&Q@CfPfb/.P2E";

    // Calcul de l'empreinte numérique (via SHA256) de la clé secrète
    $key = hash("sha256", $secret_key);

    // Calcul de l'empreinte numérique (via SHA256) du vecteur d'initialisation
    $hashedIv = hash("sha256", $secret_iv);
    // Puis troncation du résultat pour ne garder que 16 caractères,
    // parce que la méthode de chiffrement AES-256-CBC s'attend à avoir
    // un vecteur d'initialisation de 16 bytes
    $iv = substr($hashedIv, 0, 16);

    // Options à passer à OpenSSL pour le chiffrement. La valeur 0 indique qu'aucune options n'est utilisée
    $cipherOptions = 0;

    // Contrôle de l'action à réaliser (chiffrement ou déchiffrement)
    if ($action == "encrypt") {
    	  // Appel à l'API OpenSSL pour chiffrer la donnée
        $output = openssl_encrypt( $string, $encrypt_method, $key, $cipherOptions, $iv );
        // Encodage en base64 du résultat de chiffrement
        $output = base64_encode($output);
    } elseif ($action == "decrypt") {
        // On décode le texte (en base64) pour avoir le flux réel à décrypter
        $encryptedValue = base64_decode($string);
    	  // Appel à l'API OpenSSL pour déchiffrer la donnée
        $output = openssl_decrypt( $encryptedValue, $encrypt_method, $key, 0, $iv );
    }

    // Renvoi de la valeur chiffrée ou déchiffrée, sous forme de chaîne de caractères
    return $output;
}
?>

Alors, mettons tout de suite au clair certains éléments :

  • Non : Il ne s’agit pas exactement du code source reçu :
    • Je l’ai indenté ➡️➡️
    • Je l’ai nettoyé 🧹🧼
    • J’y ai ajouté des commentaires pour plus de clarté sur son contenu 👓
    • Mais en dehors de ça…
  • Oui : La clé de chiffrement est en dur dans le code
    • Non : Ce n’est pas la clé de chiffrement originale ! … je ne suis pas fou non plus 😅
    • Oui : Les développeurs actuels ne sont pas à l’aise avec ça, mais ils n’ont pas la main sur les éléments d’infrastructure (afin d’ajouter un Vault par exemple), et reçoivent d’autres priorités de réalisation au quotidien

Attention : Même si la conception de cette méthode n’est pas optimale, je ne cherche pas à blâmer les développeurs originaux du site : je ne connais pas le contexte dans lequel il a été produit.
Et je ne blâme pas non plus l’équipe qui a récupéré le projet en TMA, puisqu’elle n’est pas maître des priorisations sur la TMA.
Enfin, les développeurs actuels déplorent eux aussi certains points dans la base de code, tels que la clé de chiffrement en dur…

Pour être tout à fait franc, je ne suis pas un expert en PHP.
J’en ai fait par le passé, mais je me considère plus comme un novice que comme un expert. C’est pour ça que je ne me suis pas alarmé quand j’ai vu ce code :

1
2
    $hashedIv = hash("sha256", $secret_iv);
    $iv = substr($hashedIv, 0, 16);

Il me semblait plus logique de recevoir un tableau d’octet en sortie de la fonction hash(...) qu’une chaîne de caractère, mais de mémoire, PHP étant faiblement typé, je me suis dit que la méthode substr permettait peut-être aussi de redimensionner un tableau d’octets ¯\_(ツ)_/¯

En possession des informations qu’il nous fallait, Matthieu et moi avons alors conçu un POC nous permettant de confirmer que l’on pouvait obtenir le même résultat cryptographique en PHP et en .Net, lorsque l’on utilisait un algorithme standard (ici AES-256-CBC).

Nous nous sommes alors servis du site https://onlinephp.io pour tester le code reçu.

En premier lieu, nous avons rajouté ce morceau de code avant la fin du fichier, afin d’appeler la méthode de chiffrement et voir le résultat final :

1
2
3
4
$texteAChiffrer = "Emilien GUILMINEAU";
$action = "encrypt";
$texteChiffré = encrypt_decrypt( $action, $texteAChiffrer );
echo $texteAChiffrer . " -> " . $texteChiffré;

Nous avons exécuté le code et obtenu le résultat suivant :

1
Emilien GUILMINEAU -> N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=

Le code fonctionne, et comme prévu, le texte final est encodé en base64.

Il ne nous restait plus qu’à faire la même chose en C# et obtenir le même résultat à partir des données entrantes.

Matthieu a alors créé une application de type console, depuis son IDE (VS 2022).

Après recherche des classes permettant de manipuler un cryptage AES en .Net, il a produit un seul fichier Program.cs contenant le code suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using System.Security.Cryptography;
using System.Text;

// Clé de chiffrement
string secretKey = "S4a5Qy@pPv2Di2jz^*WMrxZM6@X5za/.ss24x";
// Vecteur d'initialisation du chiffrement
string secretIv = "i6qwQgZS&Q@CfPfb/.P2E";

// Transformation des secrets en tableau d'octets en vu de la signé numériquement via le mécanisme SHA256
byte[] hashKey = SHA256.HashData(Encoding.ASCII.GetBytes(secretKey));
byte[] hashIv = SHA256.HashData(Encoding.ASCII.GetBytes(secretIv));

string texteAChiffrer = "Emilien GUILMINEAU";
var texteChiffré = Encrypt(texteAChiffrer, hashKey, hashIv);
Console.WriteLine($"{texteAChiffrer} -> {texteChiffré}");

string Encrypt(string plainText, byte[] key, byte[] iv) {
    // Création d'un nouvel objet AES pour effectuer un chiffrement symétrique
    Aes encryptor = Aes.Create();
    encryptor.Mode = CipherMode.CBC;

    // Affectation de la clé et du vecteur d'initialisation à l'objet AES
    byte[] aesKey = new byte[key.Length];
    Array.Copy(key, 0, aesKey, 0, key.Length); // Copie des données de la clé d'origine dans l'objet de chiffrement AES
    encryptor.Key = aesKey;

    byte[] aesIV = new byte[16];
    Array.Copy(iv, 0, aesIV, 0, 16); // Tout comme dans l'exemple PHP, limitation de la taille du vecteur d'initialisation à 16 octets
    encryptor.IV = aesIV;

    // Création des flux mémoire, outil de chiffrement et flux d'écriture des données à chiffrer
    using MemoryStream memoryStream = new MemoryStream();
    using ICryptoTransform aesEncryptor = encryptor.CreateEncryptor();
    using CryptoStream cryptoStream = new CryptoStream(memoryStream, aesEncryptor, CryptoStreamMode.Write);

    // Conversion du texte en clair, en un tableau d'octets
    byte[] plainBytes = Encoding.ASCII.GetBytes(plainText);
    // Chiffrement du texte en clair
    cryptoStream.Write(plainBytes, 0, plainBytes.Length);
    // Terminaison du flux de chiffrement
    cryptoStream.FlushFinalBlock();

    // Récupération du texte chiffré présenta dans le flux mémoire
    byte[] cipherBytes = memoryStream.ToArray();

    // Fermeture du flux mémoire et du flux d'écriture des données à chiffrer
    memoryStream.Close();
    cryptoStream.Close();

    // Conversion en base64 et renvoi en sortie du texte chiffré
    return Convert.ToBase64String(cipherBytes, 0, cipherBytes.Length);
}

Après exécution du code, via un dotnet run, on obtenait le résultat suivant :

1
Emilien GUILMINEAU -> 44gVGt+090qlgHXpbA/bIe5rIEqKZM6/TUYIhMza1uM=

Aïe !

En PHP, le résultat du chiffrement est N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=.
En C#, le résultat du chiffrement est 44gVGt+090qlgHXpbA/bIe5rIEqKZM6/TUYIhMza1uM=.

Deux résultats bien distincts…
Mais pourquoi donc ?

Nous arrivions en fin de journée. J’ai alors dit à Matthieu de me faire suivre tout ce qu’il avait en sa possession pour que je puisse continuer notre investigation, seul, le soir même à la maison.

Le meilleur moyen de trouver l’origine de cette différence est de contrôler les valeurs des éléments tout au long de l’exécution du code.

Étant convaincu que l’origine du problème devait être bénigne, je me suis rabattu vers la solution de débogage la plus simple qui soit : l’émission de logs !

J’ai donc commencé par modifier le code PHP, pour faire apparaître les valeurs des empreintes numériques du secret et du vecteur d’initialisation :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...
    // un vecteur d'initialisation de 16 bytes
    $iv = substr($hashedIv, 0, 16);

    echo "Hash de la clé secrète : " . $key . "\r\n";
    echo "Hash du vecteur d'init : " . $iv . "\r\n";

    // Options à passer à OpenSSL pour le chiffrement. La valeur 0 indique qu'aucune options n'est utilisée
    $cipherOptions = 0;
...

Puis j’ai appliqué le même principe sur le code C#.
En premier lieu, j’ai ajouté une méthode permettant d’afficher le contenu d’un tableau sous sa forme hexadécimale dans la console :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
string ToHexString(byte[] hashedBytes) {
    var result = new StringBuilder();

    for (int i = 0; i < hashedBytes.Length; i++) {
        // Le format "x2" permet d'afficher la valeur hexadécimale d'un octet
        result.Append(hashedBytes[i].ToString("x2"));
    }

    return result.ToString();
}

Ensuite, j’ai ajouté de logs pour afficher les 2 secrets :

1
2
3
4
5
6
7
8
9
...
    encryptor.IV = aesIV;

    Console.WriteLine($"Hash de la clé secrète : {ToHexString(encryptor.Key)}");
    Console.WriteLine($"Hash du vecteur d'init : {ToHexString(encryptor.IV)}");

    // Création des flux mémoire, outil de chiffrement et flux d'écriture des données à chiffrer
    using MemoryStream memoryStream = new MemoryStream();
...

Enfin, dans le but d’avoir le plus d’informations possible pour déboguer cette situation, j’ai décidé d’afficher le résultat brut en sortie du chiffrement.

J’ai donc ajouté le log suivant dans le code PHP :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...
    // Appel à l'API OpenSSL pour chiffrer la donnée
    $output = openssl_encrypt( $string, $encrypt_method, $key, $cipherOptions, $iv );

    // Affichage du résultat brut de chiffrement
    echo "Résultat brut du chiffrement : " . $output . "\r\n";

    // Encodage en base64 du résultat de chiffrement
    $output = base64_encode($output);
...

Et j’ai rajouté un log équivalent dans le code C# :

1
2
3
4
5
6
7
8
...
    cryptoStream.Close();

    Console.WriteLine($"Résultat brut du chiffrement : {ToHexString(cipherBytes)}");

    // Conversion en base64 et renvoi en sortie du texte chiffré
    return Convert.ToBase64String(cipherBytes, 0, cipherBytes.Length);
...

J’ai exécuté le code C#, et j’ai obtenu le résultat textuel suivant :

1
2
3
4
Hash de la clé secrète : 185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
Hash du vecteur d'init : a97110e8dc1a0d0e647e6b34c8cec055
Résultat brut du chiffrement : e388151adfb4f74aa58075e96c0fdb21ee6b204a8a64cebf4d460884ccdad6e3
Emilien GUILMINEAU -> 44gVGt+090qlgHXpbA/bIe5rIEqKZM6/TUYIhMza1uM=

J’ai exécuté le code PHP, et j’ai obtenu le résultat textuel suivant :

1
2
3
4
Hash de la clé secrète : 185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
Hash du vecteur d'init : a97110e8dc1a0d0e
Résultat brut du chiffrement : 7U7yEBLEboWkwz/GseLIhR9xL811omLn+Hvn3/KL+Ow=
Emilien GUILMINEAU -> N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=

J’ai donc très rapidement émis les constats suivant :

  • 🤩 PHP et C# indique la même valeur pour l’empreinte numérique de la clé secrète : 185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
  • 🤨 Pourquoi les vecteurs d’initialisations en PHP et C# ne font pas la même taille, alors qu’ils commencent tous les 2 par a97110e8dc1a0d0e ?
  • 🤯 Pourquoi le résultat brut de chiffrement en PHP est déjà encodé en base64 ?

À partir de là, il me semblait clair que ma compréhension du code PHP était erronée.

Il devenait important de prendre du recul et d’effectuer un RCA.

Pour y répondre, j’ai consulté la documentation de PHP, et plus particulièrement le fonctionnement de la méthode openssl_encrypt : https://www.php.net/manual/fr/function.openssl-encrypt

Le paramètre option de la méthode `openssl_encrypt`

Ce paramètre étant passé à 0 dans notre code, je me suis douté qu’aucune transformation n’était faite sur le résultat, et que le flag OPENSSL_RAW_DATA n’était pas appliqué. En revanche, je ne trouvais aucune information explicite indiquant que le résultat était en hexadécimale par défaut.

Je me suis donc plongé dans le code source de PHP, écrit en langage C, et je suis tombé sur ces 2 conditions :

  1. https://github.com/php/php-src/blob/php-8.2.1/ext/openssl/openssl.c#L7441
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
if (options & OPENSSL_RAW_DATA) { // On NE rentre PAS dans cette condition dans notre cas
  ZSTR_VAL(outbuf)[outlen] = '\0';
  ZSTR_LEN(outbuf) = outlen;
} else { // Par contre, on rentre bien dans celle-ci
  zend_string *base64_str;
  // Et un encodage en base64 a bien lieu
  base64_str = php_base64_encode((unsigned char*)ZSTR_VAL(outbuf), outlen);
  zend_string_release_ex(outbuf, 0);
  outbuf = base64_str;
}
  1. https://github.com/php/php-src/blob/php-8.2.1/ext/openssl/openssl.c#L7547
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if (!(options & OPENSSL_RAW_DATA)) { // On rentre dans cette condition dans notre cas
  // Et un encodage en base64 a bien lieu
  base64_str = php_base64_decode((unsigned char*)data, data_len);
  if (!base64_str) {
    php_error_docref(NULL, E_WARNING, "Failed to base64 decode the input");
    EVP_CIPHER_CTX_free(cipher_ctx);
    return NULL;
  }
  data_len = ZSTR_LEN(base64_str);
  data = ZSTR_VAL(base64_str);
}

Et voilà !

J’obtenais ainsi la confirmation que la sortie en base64 était native avec les paramètres que nous passions à la méthode openssl_encrypt.

Le code PHP effectue donc un double encodage en base64 qui n’est pas nécessaire, mais je me devais de le respecter dans le code C# pour être sûr de pouvoir insérer des données lisibles pour PHP.

J’ai donc modifié mon code C# de cette manière :

1
2
3
4
5
6
7
string texteAChiffrer = "Emilien GUILMINEAU";
var texteChiffré = Encrypt(texteAChiffrer, hashKey, hashIv);

var texteChiffréBytes = Encoding.ASCII.GetBytes(texteChiffré);
var texteEncodé = Convert.ToBase64String(texteChiffréBytes, 0, texteChiffréBytes.Length);

Console.WriteLine($"{texteAChiffrer} -> {texteEncodé}");

En définitive, l’affichage hexadécimal dans les logs émis via echo en PHP, n’était pas lié à un comportement « magique » de PHP, qui aurait nativement converti un tableau d’octet en chaîne de caractères hexadécimale par commodité, mais lié au fait que la méthode hash renvoi une représentation hexadécimale de l’empreinte numérique.

Note : Vous trouverez plus de détails sur les flags openssl ici

Là encore, j’ai cherché dans la documentation de PHP, afin de comprendre le fonctionnement de la méthode hash : https://www.php.net/manual/fr/function.hash.php

Valeurs de retour de la méthode `hash`

Il s’avère que contrairement à ce que je pensais, dans notre cas cette méthode renvoie une chaîne de caractères, et non un tableau d’octets comme en C#. En effet, notre code PHP ne passe pas le paramètre binary, valorisé à true, à la méthode lors de son appel.

En conséquence, mon interprétation de l’appel à la méthode substr mentionné précédemment était erronée.
En effet, si dans le code C# la valeur du vecteur d’initialisation est affichée sur 32 caractères, c’est parce que la représentation hexadécimale d’un octet se fait sur 2 caractères hexadécimaux.
Et 16 octets * 2 caractères par octet = 32 caractères affichés.

Or, ici, le développeur du code PHP n’a pas cherché à obtenir un tableau contenant 16 octets, mais bien une chaîne de caractères contenant 16 caractères.

Ce qui m’a amené à cette formidable interrogation :

Mais pourquoi ?

Cette décision me perturbait vraiment !
Je n’arriverai pas à comprendre la logique qui avait poussé le développeur à tronquer la chaîne de caractères de l’empreinte numérique pour qu’elle ne contienne que 16 caractères.

Bien décidé à le comprendre, j’ai continué à explorer le code source de PHP sur GitHub et après quelques instants, j’ai compris ce qui m’échappait en voyant cette signature de méthode :

1
2
3
4
5
6
7
8
9
PHP_OPENSSL_API zend_string* php_openssl_encrypt(
	const char *data, size_t data_len,
	const char *method, size_t method_len,
	const char *password, size_t password_len,
	zend_long options,
	const char *iv, size_t iv_len,
	zval *tag, zend_long tag_len,
	const char *aad, size_t aad_len)
{

Mon incompréhension venait du mot-clé char.

Oh ! Je sais parfaitement ce qu’est un char en C, mais je n’avais pas percuté qu’un char est encodé sur 1 octet en C, et que la chaîne de caractères passée à la méthode openssl_encrypt utilisait l’encodage ASCII, pour lequel 1 caractère = 1 octet.

EURÊKA !

Si le développeur original a tronqué la chaîne à 16 caractères, c’est pour qu’elle ne contienne que les 16 octets obligatoires pour le vecteur d’initialisation !

Il ne me restait plus qu’à contrôler que l’encodage de la chaîne de caractères était bien en ASCII pour que mon raisonnement soit validé :

1
2
3
4
...
    echo "Hash de la clé secrète : " . mb_detect_encoding($key) . " -> " . $key . "\r\n";
    echo "Hash du vecteur d'init : " . mb_detect_encoding($iv) . " -> " . $iv . "\r\n";
...

Et le résultat m’a donné raison, c’était bien de l’ASCII :

1
2
3
4
Hash de la clé secrète : ASCII -> 185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
Hash du vecteur d'init : ASCII -> a97110e8dc1a0d0e
Résultat brut du chiffrement : 7U7yEBLEboWkwz/GseLIhR9xL811omLn+Hvn3/KL+Ow=
Emilien GUILMINEAU -> N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=

Je vous la refais pour que vous compreniez le traitement que subissent les données afin d’être interprétées par openssl lors du chiffrement :

Le vecteur d’initialisation est instancié avec cette valeur :

Format Valeur
Brut i6qwQgZS&Q@CfPfb/.P2E

L’empreinte numérique est calculée, puis retournée dans une chaîne de caractères au format hexadécimale :

Format Valeur
Hexadécimale a97110e8dc1a0d0e647e6b34c8cec055e6c0f6529d0776509eaf5de5ead82ba2

En réalité, sous sa forme brute constituée de nombres entiers, l’empreinte numérique a ces valeurs-là :

Format #1 #2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16 #17 #18 #19 #20 #21 #22 #23 #24 #25 #26 #27 #28 #29 #30 #31 #32
Hexadécimale a9 71 10 e8 dc 1a 0d 0e 64 7e 6b 34 c8 ce c0 55 e6 c0 f6 52 9d 7 76 50 9e af 5d e5 ea d8 2b a2
Nombre entier 169 113 16 232 220 26 13 14 100 126 107 52 200 206 192 85 230 192 246 82 157 7 118 80 158 175 93 229 234 216 43 162

L’empreinte numérique est ensuite tronquée à 16 caractères :

Format Valeur
Hexadécimale tronquée a97110e8dc1a0d0e

En tronquant la chaîne de caractères à 16 caractères, si on garde la logique de conversion « hexadécimal vers nombre entier », on obtient une empreinte numérique de seulement 8 octets :

Format #1 #2 #3 #4 #5 #6 #7 #8
Hexadécimale a9 71 10 e8 dc 1a 0d 0e
Nombre entier 169 113 16 232 220 26 13 14

Or, l’algorithme de chiffrement impose un vecteur d’initialisation de 16 octets !

Et bien c’est là que la partie WTF du traitement commence.

Chaque caractère de l’empreinte numérique tronquée est alors considéré comme un octet à part entière, grâce à la table des caractères ASCII.

Le code PHP n’en a alors plus rien à faire de la forme hexadécimale obtenue à la suite du calcul de l’empreinte numérique, et on obtient le tableau de conversion suivant :

Format #1 #2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16
Caractère ASCII a 9 7 1 1 0 e 8 d c 1 a 0 d 0 e
Valeur entière ASCII 97 57 55 49 49 48 101 56 100 99 49 97 48 100 48 101

Ce qui nous permet de retomber sur un tableau de 16 octets (-‸ლ)

Are you serious?

Pour confirmer mon idée, j’ai implémenté une fonction capable de m’indiquer les nombres entiers associés à chaque caractère :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function show_int_values($string) {
    $result = '';

    $chars = str_split($string);
    foreach ($chars as $char) {
      $result .= ord($char) . "-";
    }

    return substr($result, 0, strlen($result) - 1);
}

Puis je l’ai utilisé pour afficher un log me permettant de voir la suite de valeurs :

1
2
3
4
...
    echo "Hash du vecteur d'init : " . mb_detect_encoding($iv) . " -> " . $iv . "\r\n";
    echo show_int_values($iv) . "\r\n";
...

Après exécution, le résultat était :

1
2
3
4
5
Hash de la clé secrète : ASCII -> 185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
Hash du vecteur d'init : ASCII -> a97110e8dc1a0d0e
97-57-55-49-49-48-101-56-100-99-49-97-48-100-48-101
Résultat brut du chiffrement : 7U7yEBLEboWkwz/GseLIhR9xL811omLn+Hvn3/KL+Ow=
Emilien GUILMINEAU -> N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=

Une fois cette mécanique comprise, la modification du code C# a alors été rapide :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
...
    Console.WriteLine($"Hash de la clé secrète : {ToHexString(encryptor.Key)}");

    // Je récupère la version hexadécimale du vecteur d'initialisation
    var hexIv = ToHexString(iv);
    // Je le tronque à 16 caractères
    hexIv = hexIv.Substring(0, 16);
    // Puis je récupère le pendant des caractères en octet, selon un encodage ASCII
    encryptor.IV = Encoding.ASCII.GetBytes(hexIv);

    Console.WriteLine($"Hash du vecteur d'init : {hexIv}");
    Console.WriteLine(encryptor.IV.Select(b => b.ToString()).Aggregate((left, right) => left + "-" + right ));

    // Création des flux mémoire, outil de chiffrement et flux d'écriture des données à chiffrer
...

Après exécution, le résultat était :

1
2
3
4
5
Hash de la clé secrète : 185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
Hash du vecteur d'init : a97110e8dc1a0d0e
97-57-55-49-49-48-101-56-100-99-49-97-48-100-48-101
Résultat brut du chiffrement : 0549bc6826d1b02aeb1c0486db772f6d1e1fffa41347da505ca7e72454431357
Emilien GUILMINEAU -> QlVtOGFDYlJzQ3JySEFTRzIzY3ZiUjRmLzZRVFI5cFFYS2ZuSkZSREUxYz0=
Enfin !

La valeur numérique du vecteur d’initialisation était identique en PHP et en C# :
97-57-55-49-49-48-101-56-100-99-49-97-48-100-48-101

Mais le résultat du chiffrement était différent :

  • En PHP, il valait N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=
  • En C#, il valait QlVtOGFDYlJzQ3JySEFTRzIzY3ZiUjRmLzZRVFI5cFFYS2ZuSkZSREUxYz0=

Si la taille du texte semble mieux correspondre, les deux résultats restent bien distincts…

En voyant ça, je savais désormais que le seul élément qui variait entre les 2 implémentations ne pouvait être que la clé de chiffrement.

Mon intuition m’a dit de retourner à l’endroit où j’avais trouvé le plus de réponses : le code source de PHP.
Et elle était bonne.

J’ai trouvé cette condition et ce code ici :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Si la taille de la clé de chiffrement est supérieure à celle autorisée par l'algorithme
if (key_len > password_len) {
  if ((OPENSSL_DONT_ZERO_PAD_KEY & options) && !EVP_CIPHER_CTX_set_key_length(cipher_ctx, password_len)) {
    php_openssl_store_errors();
    php_error_docref(NULL, E_WARNING, "Key length cannot be set for the cipher algorithm");
    return FAILURE;
  }
  // On la réécrit ...
  key = emalloc(key_len);
  // ... pour qu'elle ait la bonne taille
  memset(key, 0, key_len);
  memcpy(key, *ppassword, password_len);
  *ppassword = (char *) key;
  *ppassword_len = key_len;
  *free_password = 1;
}

Ce morceau de code redimensionne en mémoire la clé de chiffrement si celle-ci dépasse la taille de clé autorisée par l’algorithme de chiffrement.

Bingo !

L’algorithme AES-256-CBC utilise une clé de 256 bits (soit 32 octets) pour encoder les données.
Et compte tenu que les mêmes types de données en C sont utilisés pour la clé de chiffrement et le vecteur d’initialisation, je suis parti du principe que le même procédé était appliqué à la clé de chiffrement et au vecteur d’initialisation : Conversion WTF via la table des caractères ASCII.

Il ne me restait donc plus qu’à appliquer le même principe de conversion que pour le vecteur d’initialisation, et de limiter la taille de la clé à 32 octets :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
...
    // Affectation de la clé et du vecteur d'initialisation à l'objet AES
    var hexKey = ToHexString(key);
    if (hexKey.Length > 32)
        hexKey = hexKey.Substring(0, 32);
    // Conversion des caractères ASCII en tableau d'octets
    encryptor.Key = Encoding.ASCII.GetBytes(hexKey);

    Console.WriteLine($"Hash de la clé secrète : {ToHexString(key)}");
...

Après exécution, le résultat était :

1
2
3
4
5
Hash de la clé secrète : 185c7ca3b9eb236810862bae4d3f9290b5866c53d4e8c40aa1c9a7c3f2ff8487
Hash du vecteur d'init : a97110e8dc1a0d0e
97-57-55-49-49-48-101-56-100-99-49-97-48-100-48-101
Résultat brut du chiffrement : ed4ef21012c46e85a4c33fc6b1e2c8851f712fcd75a262e7f87be7dff28bf8ec
Emilien GUILMINEAU -> N1U3eUVCTEVib1drd3ovR3NlTEloUjl4TDgxMW9tTG4rSHZuMy9LTCtPdz0=

🎉 YOUHOU !!!

Les résultats sont enfin équivalents !

Que doit-on retenir de tout ça ?
Et bien que malgré la facilité de lecture du code PHP initialement reçu par les développeurs du site, le processus et les méthodes de cryptographies mises à disposition par PHP ne sont pas forcément les plus adéquats pour obtenir un code facilement transposable à d’autres langages/technologies.

Que l’on s’entende bien, je ne blâme PHP pour le plaisir de le blâmer.
PHP fait super bien le travail de manière globale, et en particulier sur le site où il est utilisé dans cet article. À tel point que choisir une autre technologie n’aurait peut-être pas réellement eu de pertinence dans le contexte courant.

En revanche, malgré sa simplicité apparente qui tend de nombreuses personnes à l’exploiter, PHP reste, comme toutes les autres technos, soumis à des choix de conceptions qui peuvent s’avérer compliqué à comprendre et maîtriser dès l’instant où l’interopérabilité est nécessaire.

Et bien simplement en utilisant les paramètres binary des méthodes hash et openssl_encrypt :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?php
...
    $secret_iv = "i6qwQgZS&Q@CfPfb/.P2E";

    // Calcul de la signature numérique (via SHA256) de la clé secrète
    $key = hash("sha256", $secret_key, true);

    // Calcul de la signature numérique (via SHA256) du vecteur d'initialisation
    $iv = hash("sha256", $secret_iv, true);
    $iv = substr($iv, 0, 16);

    // Options à passer à OpenSSL pour le chiffrement. La valeur 0 indique qu'aucune options n'est utilisée
    $cipherOptions = 0;
...
?>

En exécutant ce code, on obtient le résultat :

1
2
Résultat brut du chiffrement : 44gVGt+090qlgHXpbA/bIe5rIEqKZM6/TUYIhMza1uM=
Emilien GUILMINEAU -> NDRnVkd0KzA5MHFsZ0hYcGJBL2JJZTVySUVxS1pNNi9UVVlJaE16YTF1TT0=

Le résultat brut du chiffrement en PHP correspond exactement à ce que la première implémentation en C# ressortait : 44gVGt+090qlgHXpbA/bIe5rIEqKZM6/TUYIhMza1uM=.

Pour clore cet article, je tenais à adresser mes félicitations à la communauté PHP pour 2 raisons :

  1. Bravo et merci de fournir une documentation très claire et, en prime, profitant d’une traduction française de qualité
  2. Bravo pour le code source clair et bien rédigé (à minima pour la partie openssl que j’ai consulté), qui m’a grandement aidé à comprendre le fonctionnement interne du framework sur cet aspect

Et un grand merci à Matthieu pour m’avoir laissé chercher la solution à ce problème, c’était fun 😁

Merci pour votre lecture, j’espère que ce retour d’expérience vous aura plu.

À bientôt !

Si vous souhaitez consulter les fichiers dans leur intégralité, dirigez-vous vers ce Gist GitHub : https://gist.github.com/XREvo/f25fcec6918fb9b38b5b5f7bd5a74a62.

Vous retrouvez les bacs à sable PHP à ces adresses :