Voici quelques manipulations qui peuvent être utiles dans les (Py)défis de crypto et particulièrement ceux sur RSA.

str / bytes

Passer d’une chaîne de caractères quelconque (par exemple une chaîne de caractère Unicode, comme Python) en une série d’octets est : l’encodage. C’est ce qui est fait lorsqu’on enregistre un fichier texte sur un disque, ou qu’on transmet une chaîne de caractères sur le réseau : il faut d’abord transformer la chaîne en suite d’octets.

Problème… il y a plusieurs façons de le faire. Avoir une correspondance biunivoque entre un caractère arbitraire et un octet, par exemple, ne fonctionne pas. Il n’y a que 256 valeurs d’octets et bien plus de caractères. C’est donc plus compliqué. L’encodage le plus simple est ASCII, mais il ne permet d’encoder que 128 caractères (et pas les caractères accentués par exemple). Les variantes de types latin1 permettent d’associer un caractère de type européen de l’ouest aux 256 valeurs d’octets possibles : à chaque valeurs d’octet correspond effectivement un caractère, mais à chaque caractère ne correspond pas un octet, car il y a bien plus de 256 caractères possibles. D’autres encodages, comme UTF8 permettent d’encoder tous les caractères unicode possibles (il y a en a vraiment beaucoup, et tous ceux que vous connaissez sont probablement dedans). Mais dans ce cas bien sûr, le code fait parfois plus de un octet. Le parfois est là car certains codes font exactement un octet (les caractères les plus usuels) et d’autres non. Ça complique le codage. La conséquence est que certaines séquences arbitraires n’ont pas forcément une signification.

En Python, on passe d’une chaîne de caractères à une séquence d’octets avec la méthode str.encode. On fait l’inverse avec bytes.decode. Dans les deux cas, on peut préciser l’encodage. Si on ne précise pas, c’est UTF8 qui est utilisé.

Encodons

Voilà quelques exemples !

>>> "Hello".encode()
b'Hello'

Ne vous y trompez pas… le b dans b'Hello' indique qu’il s’agit d’un objet de type bytes. Mais lorsque l’octet correspond à un caractère ASCII imprimable, Python l’imprime plutôt que donner la valeur de l’octet. Si on veut voir les valeurs numériques des octets, on peut faire ainsi :

>>> list("Hello".encode())
[72, 101, 108, 108, 111]

Et s’il y a un accent ?

>>> "Bonne journée".encode()
b'Bonne journ\xc3\xa9e'
>>> list("Bonne journée".encode())
[66, 111, 110, 110, 101, 32, 106, 111, 117, 114, 110, 195, 169, 101]

Au passage, "Bonne journée" contient 13 caractères (l’espace est un caractère), mais l’encodage en bytes en contient 14. Comme on le voit, le é est codé avec deux octets : 195 et 169. C’est de l'UTF8, puisqu’on a pas précisé d’encodage.

Que se passe-t-il avec du latin1 ?

>>> "Bonne journée".encode(encoding="latin1")
b'Bonne journ\xe9e'
>>> list("Bonne journée".encode(encoding="latin1"))
>>> [66, 111, 110, 110, 101, 32, 106, 111, 117, 114, 110, 233, 101]

Cette fois-ci, il y a 13 octets. L’encodage en latin1 contient toujours exactement autant d’octets que la chaîne initiale contenait de caractères.

Mais attention, latin1 ne permet pas d’encoder toutes les chaînes unicode :

>>> "I ♥ Python".encode(encoding="latin1")
---------------------------------------------------------------------------
UnicodeEncodeError                        Traceback (most recent call last)
----> 1 "I ♥ Python".encode(encoding="latin1")
UnicodeEncodeError: 'latin-1' codec can't encode character '\u2665' in position 2: ordinal not in range(256)

En effet, le caractère ne fait pas partie de la table latin1. Sans aller chercher des symboles compliqués, les caractères : ou π ne sont pas non plus dans la table latin1. Par contre, d’autres tables les contiennent : latin9 contient par exemple, mais comme toutes ces tables n’ont que 256 caractères possibles, si on en ajoute un, il faut en retirer un autre…

Au contraire, UTF8 permet d’encoder tous les caractères Unicode :

>>> "I ♥ Python : π€".encode()
b'I \xe2\x99\xa5 Python : \xcf\x80\xe2\x82\xac'
>>> list("I ♥ Python : π€".encode())
[73, 32, 226, 153, 165, 32, 80, 121, 116, 104, 111, 110, 32, 58, 32, 207, 128, 226, 130, 172]

Dans ce cas, la chaîne fait 15 caractères et elle est encodée en 20 octets.

Décodons

Et dans l’autre sens ? Si on a une suite d’octets, et qu’on veut la convertir en chaîne ? Il suffit d’utiliser bytes.decode et de préciser l’encodage.

Mais il faut déjà avoir l’objet de type bytes. On peut faire comme ça, à partir d’une liste d’entiers (qui doivent tous être entre 0 et 255 inclus bien sûr) :

>>> bytes([65, 66, 67])
b'ABC'

Et donc :

>>> bytes([65, 66, 67]).decode()
'ABC'
>>> bytes([65, 66, 67]).decode(encoding="latin1")
'ABC'

Comme on l’a vu, latin1 (ou latin9) et UTF8 ont le bon goût de coïncider sur les caractères les plus usuels… Mais attention :

>>> octets = bytes([87, 97, 114, 110, 105, 110, 103, 32, 58, 32, 164, 46])
>>> octets.decode(encoding="latin1")
'Warning : ¤.'
>>> octets.decode(encoding="latin9")
'Warning : €.'

Et si tout Unicode peut être encodé en UTF8, par contre, une séquence arbitraire d’octets n’est pas forcément de l'UTF8 valide… :

>>> octets = bytes([87, 97, 114, 110, 105, 110, 103, 32, 58, 32, 164, 46])
>>> octets.decode()
---------------------------------------------------------------------------
UnicodeDecodeError                        Traceback (most recent call last)
----> 1 octets.decode()
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa4 in position 10: invalid start byte

Dans ce cas, on peut demander à Python de simplement ignorer les octets qui posent problème :

>>> octets = bytes([87, 97, 114, 110, 105, 110, 103, 32, 58, 32, 164, 46])
>>> octets.decode(errors='ignore')
'Warning : .'

Une séquence d’octets arbitraire peut toujours être décodée en latin1 ou latin9, sans qu’il y ait d’erreurs (certains caractères ne seront peut être pas corrects). Au contraire, UTF8 permet d’encoder n’importe quelle chaîne unicode, mais une séquence arbitraire ne sera pas forcément un encodage UTF8 valide.

int / bytes

Oublions un instant les caractères et revenons aux octets. Par exemple, les octets 2 et 255. En binaire, sur 8 bits, ça donne : 00000010 et 11111111. Si on les mets bout à bout, on obient : 00000010 11111111ou bien 11111111 00000010 suivant l’ordre dans lequel on les met. Et si on lit le nombre codé sur 16 bits ainsi obtenu, on trouve 767 dans le premier cas et 65282 dans le second.

Ces transformations sont standard. On a des octets, on les colle bout à bout, et on regarde le nombre obtenu. Selon l’ordre dans lequel on met les octets, on n’a bien sûr pas le même résultat. Le choix de l’une ou l’autre convention, s’appelle en français le Boutisme (voir wikipédia), mais on trouvera plus fréquemment le terme anglais endianness. Les deux conventions sont alors appelées big endian et little endian ou en français gros boutisme et petit boutisme.

Personnellement, je mélange systématiquement les deux 😃. Peut être que l’écrire me permettra de m’en souvenir une fois pour toutes : la convention big endian consiste à mettre l’octet de poids fort au début (si on enregistre en mémoire, on met l’octet de poids fort à l'adresse la plus petite).

Si je considère le nombre 767, qui s’écrit en binaire (je complète à un nombre entier d’octets, c’est à dire que je complète à un multiple de 8 pour le nombre de bits) : 00000010 11111111. L’octet de poids fort est 00000010, et en notation big endian, on écrira donc les deux octets 2, 255. En little endian, on écrira 255, 2.

Des int en bytes

Python sait faire tout ça :

>>> v = 767
>>> list(v.to_bytes(2, byteorder="big"))
[2, 255]
>>> list(v.to_bytes(2, byteorder="little"))
[255, 2]

L’utilisation de list n’est là que pour voir la valeur des octets. Il faut retenir que int.to_bytes renvoie un objet de type bytes.

La valeur 2 permet de préciser à Python que le résultat doit être mis sur deux octets. On peut demander plus d’octets :

>>> v = 767
>>> list(v.to_bytes(5, byteorder="big"))
[0, 0, 0, 2, 255]

Mais on ne peut pas en demander moins…

>>> v = 767
>>> list(v.to_bytes(1, byteorder="big"))
---------------------------------------------------------------------------
OverflowError                             Traceback (most recent call last)
----> 1 list(v.to_bytes(1, byteorder="big"))
OverflowError: int too big to convert

A priori, il n’y a pas d’option pour demander d’utiliser exactement le nombre minimal d’octets nécessaire. Curieux.

Des bytes en int

Dans l’autre sens, c’est possible aussi :

>>> int.from_bytes(bytes([2, 255]), byteorder="big")
767
>>> int.from_bytes(bytes([2, 255]), byteorder="little")
65282

À noter que le premier paramètre doit être un itérable qui peut produire des octets. Donc int.from_bytes([2, 255], byteorder="big") fonctionne très bien, mais c’est un peu moins consistant…

Pourquoi deux conventions ?

Les deux conventions d'endianness coexistent encore. On retrouve l’une ou l’autre pour l’écriture des nombres en mémoire selon le type de processeur  : les x86 sont petit-boutistes, les 680x0 (de plus en plus rares je crois) sont gros-boutistes. Lors d’un transfert sur un réseau TCP/IP, on utilise le gros-boutisme. Certains microprocesseurs peuvent faire les deux, et ce choix peut aussi intervenir au niveau de l’OS… En bref, il vaut mieux savoir que ça existe, on tombe dessus un jour ou l’autre.

int / bytes / str

Finalement, puisqu’on peut transformer des chaînes en octets et des séries d’octets en un nombre entier…. on peut transformer des chaînes en un nombre entier (assez grand quand même…). Il faut juste choisir deux choses : l’encodage, et la convention endianness.

Voilà un exemple :

>>> chaine = "Chaîne accentuée"
>>> octets = chaine.encode()  # utf8 par défaut
>>> int.from_bytes(octets, byteorder="big")
5872042474739078342698532311750291705014629

Et dans l’autre sens :

>>> val = 5872042474739078342698532311750291705014629
>>> octets = val.to_bytes(18, byteorder="big")
>>> octets 
b'Cha\xc3\xaene accentu\xc3\xa9e'
>>> octets.decode()
'Chaîne accentuée'

La valeur 18 est la plus petite qui fonctionne. Si on met plus, on aura des octets nuls au début de octets (ce qui n’empêchera pas le décodage UTF8). Si on en met moins, le nombre d’octets n’est pas suffisant et to_bytes échouera. On peut aussi trouver la valeur ainsi (on rappelle que le logarithme en base 2 d’un nombre entier indique presque le nombre de chiffres qui le composent en binaire) :

>>> import math
>>> val = 5872042474739078342698532311750291705014629
>>> math.ceil(math.log(val + 1, 2) / 8)
18

Mais attention, si on se trompe dans l'endianness ça peut être plus ou moins grave. Si l’encodage est du latin1, on aura juste la chaîne à l’envers puisque chaque caractère est sur 1 octet exactement. Mais en UTF8, puisque certains caractères sont codés sur plusieurs octets, ça compte…

>>> val = 5872042474739078342698532311750291705014629
>>> octets = val.to_bytes(18, byteorder="little")
---------------------------------------------------------------------------
UnicodeDecodeError                        Traceback (most recent call last)
----> 1 octets.decode()
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa9 in position 1: invalid start byte

Si l'endianness est correct, mais qu’on se trompe d’encodage, on peut avoir des erreurs à l’exécution (si on indique de l'UTF8 alors que ça n’en est pas) ou juste certains caractères incorrects sans erreur signalée :

>>> val = 5872042474739078342698532311750291705014629
>>> octets = val.to_bytes(18, byteorder="big")
>>> octets.decode(encoding="latin1")
'Chaîne accentuée'

Et si on se trompe dans les deux… (ici il n’y aura pas d’erreur puisqu’on demande de décoder du latin1) :

>>> val = 5872042474739078342698532311750291705014629
>>> octets = val.to_bytes(18, byteorder="little")
>>> octets.decode(encoding="latin1")
'e©Ãutnecca en®ÃahC'

Alors n’oubliez pas : 2879508659154802517374769396498418170889874086922626359629