Décodage Base64 en C : un guide complet
Le voici dans une réponse d'API, un fichier de configuration, une pièce jointe d'e-mail ou au milieu d'une URL : une longue chaîne de lettres et de chiffres, un + ou un / de temps en temps, et parfois un ou deux = à la fin. Vous le reconnaissez instantanément, et maintenant il vous faut récupérer les octets d'origine - en C. C'est tout le travail du décodage base64 : quatre caractères de l'alphabet entrent, trois octets bruts sortent, encore et encore, jusqu'à ce que les marques = vous disent où les vraies données se sont arrêtées. La page d'accueil de ce site déroule le format pas à pas, alors cet article dépense son énergie là où est le vrai travail : les buffers, les bibliothèques, et les pièges qui se cachent entre les deux.
Deux choses à savoir avant le premier malloc. D'abord, le décodage est la direction qui réduit : la sortie fait trois quarts de la taille de l'entrée, donc un décodeur n'a jamais besoin de plus de mémoire que le payload qu'il tient déjà en main. Ensuite - et c'est la grande nouvelle - C ne livre aucun décodeur base64. La bibliothèque standard du langage s'est figée bien avant que base64 existe, et aucune norme depuis n'a comblé le vide. Chaque programme C qui décode du base64 s'appuie donc sur une bibliothèque, et les quatre qui comptent en pratique sont OpenSSL, Mbed TLS, APR-Util et GLib. Chacune a sa personnalité : ce qu'elle pardonne, comment elle signale les erreurs, et ce qu'elle fait en silence à votre sortie. Une fois que vous connaissez la personnalité de votre décodeur, décoder du base64 en C cesse d'être une source de bugs mystérieux et devient une routine que vous pouvez écrire en dormant.
La boîte à outils : quatre façons de récupérer vos octets
Voici le paysage d'un coup d'œil. Les quatre couvrent l'alphabet standard ; les différences sont dans les bords, et les bords sont là où naissent les bugs.
| Bibliothèque | En-tête | Modèle d'erreur | Particularité de la sortie à retenir |
|---|---|---|---|
| OpenSSL (libcrypto) | <openssl/evp.h> |
Renvoie -1 sur entrée invalide |
Le décodeur one-shot bourre la fin de zéros |
| Mbed TLS | <mbedtls/base64.h> |
Codes de retour (-0x002C, -0x002A) |
Les règles d'entrée les plus strictes des quatre |
| APR-Util | <apr-1.0/apr_base64.h> |
Aucun : s'arrête au premier caractère bizarre | API à longueurs int, donc 2 Go au plafond |
| GLib | <glib.h> |
Renvoie NULL uniquement en cas d'échec dur |
Ignore silencieusement la saleté égarée |
L'installation, c'est un nom de paquet par distribution. Pour OpenSSL : libssl-dev sur Debian et Ubuntu, openssl-devel sur Fedora et RHEL, openssl sur Arch, et brew install openssl sur macOS. Pour Mbed TLS : libmbedtls-dev (ou mbedtls). Pour APR-Util : libaprutil1-dev plus libapr1-dev. Pour GLib : glib2.0-dev. Ensuite vous liez avec -lcrypto, -lmbedcrypto, -laprutil-1 ou -lglib-2.0 respectivement. Laquelle choisir ? Si vous liez déjà OpenSSL pour du TLS ou du hachage (la plupart des serveurs le font), utilisez OpenSSL. Pour l'embarqué et les builds aux ressources contraintes, Mbed TLS est le petit citoyen strict. Si vous êtes dans l'écosystème Apache, APR-Util est déjà là. Si votre code est basé sur GNOME ou GTK, GLib garde tout dans un seul runtime.
OpenSSL : le décodeur qui remplit les trous de zéros
OpenSSL transporte base64 en deux variantes. La fonction one-shot est la star de la plupart du code :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
unsigned char out[16];
const char *payload = "TWFuZQ==";
int n = EVP_DecodeBlock(out, (const unsigned char *)payload,
(int)strlen(payload));
if (n < 0) {
printf("not base64\n");
return 1;
}
printf("%d bytes\n", n);
return 0;
}
Donnez-lui un buffer de caractères base64 et une longueur, et il écrit les octets décodés dans out et renvoie combien il y en a. Il coupe les blancs au début, coupe les blancs et les retours à la ligne à la fin, et il refuse une entrée qui n'est pas un multiple de quatre caractères après nettoyage, ou qui contient un caractère hors de l'alphabet. Jusqu'à présent, un contrat parfaitement sensé. Sauf un détail qui a discrètement corrompu plus d'une importation de base de données : la valeur de retour n'est pas la vraie longueur des données.
Lancez ce programme et vous obtiendrez 4 bytes... non, attendez. TWFuZQ== fait deux groupes de quatre caractères, donc la fonction renvoie 6, et le buffer contient 4d 61 6e 65 00 00 : le mot « Mane » plus deux octets nuls. Le décodeur one-shot d'OpenSSL travaille en quanta fixes - quatre caractères d'entrée produisent toujours exactement trois octets de sortie - et quand le dernier groupe ne portait qu'un seul octet réel, les deux autres cases sont remplies de zéros. Le manuel en fait mention dans une seule phrase toute calme (« la sortie sera bourrée de bits 0 si nécessaire »), et cette phrase est la plus importante de toute la page de manuel de cette fonction.
La vraie longueur se récupère depuis le padding, et c'est un calcul en deux lignes :
size_t real_length(const char *b64) {
size_t len = strlen(b64);
while (len > 0 && b64[len - 1] == '=') len--;
return len * 3 / 4;
}
Comptez les caractères de l'alphabet, supprimez les pads de fin, multipliez par trois, divisez par quatre. Pour TQ== (la lettre M, encodée), ça donne (2 * 3) / 4 = 1 octet réel - alors que EVP_DecodeBlock en annoncera trois. Gardez toujours la paire (pointeur, longueur) ensemble, et n'utilisez jamais strlen sur des données décodées, parce que les octets que vous avez reçus peuvent être un JPEG et que le premier d'entre eux peut être un NUL.
Le décodeur en streaming : un décodeur qui sait quand s'arrêter
Pour le reste, OpenSSL offre la paire en streaming EVP_DecodeUpdate plus EVP_DecodeFinal. L'objet de contexte est ce qui transporte l'état entre les appels : il garde en mémoire un à trois caractères d'un groupe incomplet pour que vous puissiez alimenter le payload par morceaux. Le comportement qui compte est celui-ci : les blancs (espaces, tabulations, retours chariot, sauts de ligne) sont sautés n'importe où dans le flux, tout autre caractère hors alphabet ou un = au milieu des données renvoient immédiatement -1, et un renvoi de 0 par l'update signifie « le padding a été vu, plus rien n'est attendu ». EVP_DecodeFinal refuse alors avec -1 si un groupe partiel est encore en attente, parce qu'une longueur qui n'est pas un multiple de quatre (après les blancs) n'est pas un payload valide.
Une note de version avant le code, parce que les vieux tutoriels peuvent vous faire trébucher : dans OpenSSL 3.x, le type de contexte EVP_ENCODE_CTX est opaque, et le pattern pile EVP_ENCODE_CTX ctx; qu'on trouve dans beaucoup de code d'internet ne compile plus. Alloquez et libérez explicitement :
static int decode_b64(const unsigned char *in, int in_len,
unsigned char *out, int *out_len) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
if (ctx == NULL) {
return -1;
}
*out_len = 0;
EVP_DecodeInit(ctx);
int r = EVP_DecodeUpdate(ctx, out, out_len, in, in_len);
if (r < 0) {
EVP_ENCODE_CTX_free(ctx);
return -1;
}
int tail = 0;
r = EVP_DecodeFinal(ctx, out + *out_len, &tail);
EVP_ENCODE_CTX_free(ctx);
if (r < 0) {
return -1;
}
*out_len += tail;
return 0;
}
Dimensionnez le buffer de sortie à in_len * 3 / 4 + 3 et l'appel est sûr pour n'importe quelle entrée. Regardez-le gérer un payload enveloppé MIME où le retour à la ligne tombe au milieu d'un groupe :
const char *wrapped = "TWFu\nZQ==";
unsigned char out[16];
int out_len = 0;
if (decode_b64((const unsigned char *)wrapped,
(int)strlen(wrapped), out, &out_len) != 0) {
printf("invalid base64\n");
return 1;
}
printf("%.*s\n", out_len, out); /* Mane */
Le retour à la ligne disparaît, les quatre octets sortent, et personne n'a eu à pré-nettoyer l'entrée. Il y a une différence bonus avec la fonction one-shot : le chemin en streaming compte les octets honnêtement. Donnez-lui TQ== et il renvoie exactement un octet (4d), sans bourrage de zéros, parce qu'il comprend que deux pads signifient que deux des trois cases de sortie n'ont jamais été remplies. Si votre payload a un jour besoin d'une longueur fiable venue d'OpenSSL, c'est ce chemin qu'il faut utiliser.
Mbed TLS : le strict
Mbed TLS (la bibliothèque de crypto qui a démarré sous le nom de PolarSSL et qui vit maintenant dans les stacks embarquées d'ARM) vous offre deux fonctions au contrat très propre :
int mbedtls_base64_encode(unsigned char *dst, size_t dlen, size_t *olen,
const unsigned char *src, size_t slen);
int mbedtls_base64_decode(unsigned char *dst, size_t dlen, size_t *olen,
const unsigned char *src, size_t slen);
Décode comme le ferait une personne prudente. Appelez-la avec dst réglé sur NULL (ou dlen à zéro) et elle vous donne la taille requise dans *olen sans faire aucun travail ; appelez-la pour de vrai et vous obtenez 0 sur succès, MBEDTLS_ERR_BASE64_INVALID_CHARACTER (c'est-à-dire -0x002C) si quelque chose cloche dans l'entrée, ou MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL (c'est-à-dire -0x002A) si la destination est trop petite. La longueur décodée atterrit dans *olen, et contrairement à la fonction one-shot d'OpenSSL c'est toujours le chiffre honnête : décoder TQ== vous donne un octet, 4d, et rien de plus.
Les règles d'entrée sont les plus strictes des quatre bibliothèques, et elles méritent d'être mémorisées parce qu'elles définissent ce que veut dire « valide » pour Mbed TLS :
- Les fins de ligne CRLF et LF peuvent apparaître entre les groupes - les payloads d'e-mail fonctionnent tels quels.
- Les espaces sont autorisés juste avant une fin de ligne et tout à la fin du buffer, mais un espace après une fin de ligne ou au milieu d'un groupe est une erreur.
- Au plus deux caractères
=, et seulement à la fin ; toute donnée après un pad est une erreur. - Tout octet au-dessus de 127 (accents, fragments UTF-8, saleté binaire) est une erreur.
C'est la dernière règle qui mord : si un payload arrive d'une source qui a cabossé l'encodage des caractères, Mbed TLS le rejette là où un décodeur plus paresseux aurait décodé en haussant les épaules. Pour tout ce qui touche des entrées non fiables, le strict est une fonctionnalité. Un décodage complet ressemble à ça :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/base64.h>
int main(void) {
const char *payload = "TWFuZQ==";
size_t need = 0;
int rc = mbedtls_base64_decode(NULL, 0, &need,
(const unsigned char *)payload,
strlen(payload));
if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
printf("size query failed: %d\n", rc);
return 1;
}
unsigned char *out = malloc(need);
size_t olen = 0;
rc = mbedtls_base64_decode(out, need, &olen,
(const unsigned char *)payload,
strlen(payload));
if (rc != 0) {
printf("decode failed: %d\n", rc);
free(out);
return 1;
}
printf("%.*s\n", (int)olen, out);
free(out);
return 0;
}
(Le fait que la requête de taille renvoie le code « trop petit » est voulu par conception : c'est ainsi que la fonction rapporte ce qu'elle aurait écrit. Les deux codes de retour ci-dessus viennent de <mbedtls/base64.h>, le même en-tête où la fonction est déclarée.)
APR-Util et GLib : deux chaises de plus
APR-Util - la bibliothèque utilitaire de l'Apache Portable Runtime, la fondation sur laquelle est construit Apache HTTP Server - transporte base64 depuis que le serveur a besoin de décoder les en-têtes d'auth Basic. L'API est une petite famille de fonctions basées sur le int :
#include <apr-1.0/apr_base64.h>
int apr_base64_encode_len(int len);
int apr_base64_encode(char *coded_dst, const char *plain_src,
int len_plain_src);
int apr_base64_decode_len(const char *coded_src);
int apr_base64_decode(char *plain_dst, const char *coded_src);
Deux choses à savoir avant de l'utiliser. D'abord, les longueurs sont des int : 32 bits, donc le plafond pratique est de 2 Go par appel, ce qui va bien pour des en-têtes et des valeurs de configuration et moins bien pour décoder un fichier de 4 Go. Ensuite - et c'est le gros - la fonction de décodage n'a tout simplement aucun retour d'erreur. Le comportement n'est visible que dans l'implémentation, pas dans l'en-tête : le décodeur prend n'importe quel caractère invalide, y compris les blancs et le NUL, comme caractère terminal. Il décode jusqu'à la première chose qu'il ne reconnaît pas, renvoie jusqu'où il est arrivé, et ne dit rien. Un payload tronqué, un copier-coller avec un commentaire en queue, un octet corrompu au milieu - tout ça produit une sortie silencieusement trop courte. Si vous utilisez le décodeur d'APR, vous devez comparer la longueur renvoyée à ce que le payload promettait ; la fonction ne le fera pas à votre place. Il n'y a pas d'enveloppe qui alloue depuis le pool - vous fournissez le buffer de destination, donc dans du code piloté par pools vous allouez plain_dst depuis le pool vous-même. Il y a aussi un angle EBCDIC que vous ne trouverez nulle part ailleurs dans cet article : sur les machines EBCDIC, les fonctions convertissent l'entrée en ASCII avant l'encodage et dans l'autre sens après le décodage, donc le même code tourne sur les mainframes qui font encore tourner httpd.
GLib, le runtime derrière GTK et la plupart des applications GNOME, prend la personnalité opposée. Son décodeur accepte une chaîne et rend toujours un buffer fraîchement alloué (NULL seulement si vous passez un pointeur NULL), décode ce qu'il peut et saute silencieusement le reste :
#include <glib.h>
gsize out_len = 0;
guchar *bytes = g_base64_decode(payload, &out_len);
if (bytes == NULL) {
printf("not base64\n");
} else {
printf("%u bytes\n", (unsigned)out_len);
g_free(bytes);
}
Le piège est dans le mot « toujours ». Le décodeur de GLib est de l'école conciliante : les caractères hors alphabet sont sautés, pas fatals. Donnez-lui TWFuZ@== et il rendra les trois octets de « Man » sans lever le petit doigt. Il y a aussi une variante in-place pratique, g_base64_decode_inplace(), qui décode par-dessus le buffer d'entrée (sûr parce que la sortie est plus courte que l'entrée) et renvoie le même pointeur, donc le résultat commence au début du buffer - un joli tour pour le code aux ressources mémoire serrées, et il avale sans broncher l'entrée enveloppée CRLF. La leçon pour les développeurs C : si vos données ne sont pas fiables, GLib ne vous sauvera pas d'un payload corrompu. Les variantes _step (g_base64_decode_step avec un entier d'état) sont disponibles quand vous avez besoin de décodage incrémental, et la paire correspondante g_base64_encode_step/g_base64_encode_close vit du côté de l'encodage.
Le base64 URL-safe : l'autre alphabet
Quelque part entre l'alphabet standard et vos URLs, quelqu'un a été blessé. Le base64 standard utilise + et / comme ses deux symboles les plus élevés, et les deux sont des ennuis dans les URLs : un + dans une chaîne de requête est couramment interprété comme un espace avant que votre serveur ne le voie, et / est un séparateur de chemin. La section 5 du RFC 4648 définit le correctif, appelé base64url : le même encodage avec + remplacé par -, / remplacé par _, et le padding de = de fin supprimé quand la longueur est connue d'une autre façon. Les JSON Web Tokens, les paramètres de state OAuth et une grande quantité d'identifiants de session d'API vivent dans ce dialecte.
Aucune des quatre bibliothèques C ne décode le base64url nativement, donc la conversion est un petit helper que vous écrivez une fois et réutilisez : re-mappez les deux caractères spéciaux, rajoutez le padding manquant, puis passez le résultat à votre décodeur standard. Vérification de la longueur d'abord, parce qu'une longueur supérieure d'un à un multiple de quatre est impossible dans n'importe quel dialecte de base64 :
int base64url_decode(const char *url_safe, unsigned char *out,
size_t out_cap, size_t *out_len) {
size_t len = strlen(url_safe);
if (len % 4 == 1) {
return -1;
}
size_t needed = (len * 3) / 4;
if (needed > out_cap) {
return -2;
}
char *std = malloc(len + 4);
if (std == NULL) {
return -3;
}
for (size_t i = 0; i < len; i++) {
char c = url_safe[i];
if (c == '-') c = '+';
if (c == '_') c = '/';
std[i] = c;
}
size_t pad = (4 - len % 4) % 4;
for (size_t i = 0; i < pad; i++) {
std[len + i] = '=';
}
int n = EVP_DecodeBlock(out, (const unsigned char *)std,
(int)(len + pad));
free(std);
if (n < 0) {
return -1;
}
*out_len = needed;
return 0;
}
Deux pièges gardent cette route. Le premier est la direction : si vous donnez un payload URL-safe au décodeur standard sans l'échange de caractères, OpenSSL et Mbed TLS le refusent (ces caractères ne sont pas dans leur alphabet), tandis que GLib sautera silencieusement les - et les _ et rendra une chaîne plus courte qu'elle ne devrait - sans aucune erreur. Passez toujours par le helper. Le second est l'avertissement du RFC lui-même, qui mérite d'être pris au sérieux : le base64url « ne devrait pas être considéré comme identique à l'encodage base64 ». Si un payload ne contient par hasard aucun caractère - ou _, les deux dialectes sont byte-identiques pour ces données, et une confusion est invisible - ce qui explique exactement pourquoi la confusion survit jusqu'à ce qu'elle tombe sur un payload qui en contient un.
Fichiers : restaurer l'original
Le boulot le plus courant en forme de fichier est l'inverse de ce qu'une routine d'export a fait : un fichier texte .b64 arrive, et il vous faut le fichier original en retour. Lisez tout le texte, décodez-le, et laissez ensuite les octets s'annoncer eux-mêmes avant de faire confiance à n'importe quelle étiquette. C n'a pas de finfo, donc le test pratique est un sniff de magic numbers sur les premiers octets :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("upload.b64", "rb");
if (f == NULL) {
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
char *text = malloc((size_t)size + 1);
size_t got = fread(text, 1, (size_t)size, f);
fclose(f);
text[got] = '\0';
unsigned char *out = malloc((got * 3) / 4 + 3);
int out_len = 0;
if (decode_b64((const unsigned char *)text, (int)got,
out, &out_len) != 0) {
printf("not valid base64\n");
free(text);
free(out);
return 1;
}
free(text);
const char *kind = "unknown binary";
if (out_len >= 4 && memcmp(out, "\x89PNG", 4) == 0) kind = "png";
else if (out_len >= 5 && memcmp(out, "%PDF-", 5) == 0) kind = "pdf";
else if (out_len >= 4 && memcmp(out, "PK\x03\x04", 4) == 0) kind = "zip";
else if (out_len >= 3 && memcmp(out, "\xff\xd8\xff", 3) == 0) kind = "jpeg";
printf("looks like a %s, %d real bytes\n", kind, out_len);
free(out);
return 0;
}
Notes sur les bords : ouvrez le fichier en mode binaire (rb/wb) même pour la moitié texte, parce que le mode texte traduira les fins de ligne sur certaines plateformes et corrompra votre compte de caractères ; et ne faites jamais printf("%s") du buffer décodé pour « voir ce que c'est ». Le sniff de magic numbers est la façon honnête de poser cette question, et si vous servez plus tard le fichier restauré à un navigateur, le Content-Type devrait venir du même sniff, pas du nom de fichier.
Les data URIs : l'image dans l'URL
Un arrivage favori du monde web : quelqu'un colle une image dans un formulaire, et le front end tend à votre serveur un data URI complet comme data:image/png;base64,iVBORw0KGgo.... Le RFC 2397 définit la forme : data:, un type média optionnel, un drapeau ;base64 optionnel, une virgule, et puis le payload. Quand le drapeau est présent, le payload est en base64 ; quand il est absent, le payload est du texte brut percent-encodé - plus rare, mais légal. Si le type média est omis, la valeur par défaut est text/plain;charset=US-ASCII. Le parser en C, c'est une question de trouver la virgule et de regarder ce qui est juste avant elle :
int split_data_uri(const char *uri, char *mime, size_t mime_cap,
int *is_b64, const char **payload) {
if (strncmp(uri, "data:", 5) != 0) {
return -1;
}
const char *comma = strchr(uri, ',');
if (comma == NULL) {
return -1;
}
*is_b64 = 0;
const char *meta = uri + 5;
size_t meta_len = (size_t)(comma - meta);
if (meta_len >= 7 && strncmp(comma - 7, ";base64", 7) == 0) {
*is_b64 = 1;
meta_len -= 7;
}
if (meta_len == 0) {
snprintf(mime, mime_cap, "text/plain;charset=US-ASCII");
} else {
snprintf(mime, mime_cap, "%.*s", (int)meta_len, meta);
}
*payload = comma + 1;
return 0;
}
Et l'appelant se lit comme une phrase :
char mime[256];
int is_b64 = 0;
const char *payload = NULL;
const char *uri = "data:image/png;base64,iVBORw0KGgo...";
if (split_data_uri(uri, mime, sizeof(mime), &is_b64, &payload) == 0) {
printf("mime=%s base64=%d\n", mime, is_b64);
/* décode maintenant le payload avec la bibliothèque de ton choix */
}
Trois pièges vivent dans ce format. Le drapeau ;base64 manquant est le premier : un data URI légal sans lui porte un payload percent-encodé, et passer ça dans un décodeur base64 produit de la purée - vérifiez le drapeau, puis choisissez votre décodeur. Le type média déclaré est le second : c'est un indice de l'expéditeur, pas un fait ; le sniff de magic numbers de la section fichiers est votre fait. Le troisième est la taille : le conseil du RFC lui-même est que les data URIs sont pour les valeurs courtes, donc une image de plusieurs mégaoctets voyageant à l'intérieur d'une URL est une odeur dans votre architecture, pas un pattern à célébrer.
Les JWT : lire les parties non secrètes
Le payload base64 le plus célèbre du web est le JSON Web Token, et le moins effrayant une fois qu'on connaît sa forme. Selon le RFC 7519, un JWT compact est trois parties base64url jointes par des points : un en-tête, un payload et une signature - chacune encodée sans padding, sans retours à la ligne. Les deux premières parties sont du JSON brut, c'est pourquoi tout le monde peut les lire, et pourquoi tout le monde devrait lire la suite avant de toucher à un token.
Lire les deux premières parties tient en quelques lignes avec le helper base64url ci-dessus, et c'est le moyen le plus rapide de démystifier un token :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int base64url_decode(const char *url_safe, unsigned char *out,
size_t out_cap, size_t *out_len);
int main(void) {
const char *token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0."
"TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ";
const char *dot1 = strchr(token, '.');
if (dot1 == NULL) {
return 1;
}
const char *part2 = dot1 + 1;
const char *dot2 = strchr(part2, '.');
if (dot2 == NULL) {
return 1;
}
const char *part3 = dot2 + 1;
char seg[512];
char buf[1024];
size_t n = 0;
size_t hlen = (size_t)(dot1 - token);
memcpy(seg, token, hlen);
seg[hlen] = '\0';
if (base64url_decode(seg, (unsigned char *)buf,
sizeof(buf), &n) == 0) {
printf("header: %.*s\n", (int)n, buf);
}
size_t plen = (size_t)(dot2 - part2);
memcpy(seg, part2, plen);
seg[plen] = '\0';
if (base64url_decode(seg, (unsigned char *)buf,
sizeof(buf), &n) == 0) {
printf("payload: %.*s\n", (int)n, buf);
}
printf("signature: %s (encoded, verify before trusting!)\n", part3);
return 0;
}
Imprimés, l'en-tête est {"alg":"HS256","typ":"JWT"} et le payload est {"sub":"1234567890","name":"John Doe"}. Maintenant la partie qui compte : la troisième partie est une signature, et les deux parties que vous venez de décoder sont ni secrètes ni authentifiées. N'importe qui avec une capture de paquets peut les lire, et n'importe qui avec un éditeur de texte peut les réécrire. Faire confiance au payload d'un JWT en C avant d'avoir vérifié la signature est le bug d'authentification classique, et base64 rend facile de ne pas le remarquer - le token a l'air d'un blob infranchissable alors qu'il est une carte postale. Pour vérifier un token HS256, vous recomputez le HMAC-SHA256 sur header.part avec votre secret en utilisant HMAC() de <openssl/hmac.h> et vous comparez en temps constant avec CRYPTO_memcmp() ; si les digests ne concordent pas, le token est rejeté, quelles que soient ses prétentions. Il n'existe pas de bibliothèque JWT standard de facto en C, donc pour la production vous construirez vous-même cette petite étape de vérification ou vous adopterez l'une des bibliothèques de la communauté - mais le côté base64 du boulot est la danse split-et-décoder ci-dessus, et vous devriez tout comprendre.
L'auth Basic : l'en-tête qui n'a jamais appris la confidentialité
L'en-tête d'authentification le plus ancien du web est toujours monté sur base64 : Authorization: Basic suivi de l'encodage à alphabet standard de username:password (RFC 7617, auquel le RFC 9110 renvoie pour le schéma Basic). Le RFC est explicite : c'est un encodage, pas une protection - n'importe qui avec une capture de paquets peut décoder les deux moitiés en une commande - donc le boulot côté décodage en C est de parser l'en-tête, décoder strictement, couper au premier deux-points (les mots de passe peuvent légalement en contenir) et comparer avec une fonction sûre au plan du temps :
#include <string.h>
#include <openssl/evp.h>
#include <openssl/crypto.h>
static size_t real_length(const char *b64);
int basic_auth_ok(const char *header, const char *expected_user,
const char *expected_pass) {
if (strncmp(header, "Basic ", 6) != 0) {
return 0;
}
const char *b64 = header + 6;
unsigned char out[256];
int n = EVP_DecodeBlock(out, (const unsigned char *)b64,
(int)strlen(b64));
if (n < 0) {
return 0;
}
size_t real = real_length(b64);
size_t u_len = strlen(expected_user);
size_t p_len = strlen(expected_pass);
if (real != u_len + 1 + p_len) {
return 0;
}
if (memcmp(out, expected_user, u_len) != 0) {
return 0;
}
if (out[u_len] != ':') {
return 0;
}
return CRYPTO_memcmp(out + u_len + 1, expected_pass, p_len) == 0;
}
La vérification de longueur fait un vrai travail : elle empêche un payload qui se décode en « alice:secret » avec une queue de saletés, ou « alice:secre » tronqué, de matcher. Et CRYPTO_memcmp (ou memcmp seulement si vous comprenez les implications temporelles) est ce qui empêche un attaquant de se frayer un chemin par le timing dans votre liste d'utilisateurs. Servez cet en-tête en HTTPS ou ne le servez pas du tout - sur une connexion en clair, la couche base64 est de la décoration.
E-mails et PEM : le foyer d'origine
Le base64 est né pour un problème très précis : le transport de courrier ne portait que de l'ASCII 7 bits, et on voulait y envoyer des binaires. Le MIME (RFC 2045) a fait du base64 l'un des encodages de transfert standard et a ajouté deux règles de maison : les lignes encodées ne doivent pas dépasser 76 caractères, et les logiciels de décodage doivent ignorer les caractères hors alphabet - retours à la ligne compris. C'est cette deuxième règle qui permet aux décodeurs en streaming ci-dessus d'avaler une pièce jointe enveloppée sans aucun prétraitement, et c'est pourquoi l'habitude des 76 caractères est encore incrustée dans toutes les bibliothèques de courrier de la planète. L'ancêtre était le PEM (Privacy Enhanced Mail, RFC 1421), qui utilisait des lignes de 64 caractères à la place - la séparation 64/76 que vous voyez dans les outils, c'est cette histoire, les deux limites étant ultimement imposées par SMTP.
L'armure PEM - le format dans lequel voyagent clés et certificats - n'est que du base64 étiqueté : une ligne -----BEGIN ... -----, le corps en lignes de 64 caractères, et une ligne END assortie. Retirer l'armure en C, c'est un scan ligne par ligne, et ensuite le décodeur fait le reste :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("server.key", "r");
if (f == NULL) {
return 1;
}
char line[256];
char b64[8192];
size_t pos = 0;
int in_body = 0;
while (fgets(line, sizeof(line), f) != NULL) {
if (strncmp(line, "-----BEGIN", 10) == 0) {
in_body = 1;
continue;
}
if (strncmp(line, "-----END", 8) == 0) {
in_body = 0;
break;
}
if (in_body) {
size_t l = strlen(line);
while (l > 0 && (line[l - 1] == '\n' || line[l - 1] == '\r')) {
l--;
}
memcpy(b64 + pos, line, l);
pos += l;
}
}
fclose(f);
unsigned char der[8192];
int out_len = 0;
if (decode_b64((const unsigned char *)b64, (int)pos,
der, &out_len) != 0) {
printf("armor contained no valid base64\n");
return 1;
}
printf("DER payload decoded\n");
return 0;
}
Les octets décodés sont du DER, une sérialisation binaire compacte, et c'est ce que consomment en fin de compte les fonctions de certificats et de clés d'OpenSSL. Deux notes : collectez le corps sans ses retours à la ligne (comme le fait la boucle) pour que votre longueur soit un multiple de quatre, et si un fichier porte plusieurs blocs, faites correspondre l'étiquette END à l'étiquette BEGIN que vous avez ouverte - un simple drapeau suffit quand vous ne voulez que le premier bloc, comme ici.
Secrets, configs et colonnes de base de données
Le base64 est un conteneur de texte, et c'est pourquoi il ne cesse de se pointer dans des endroits inattendus. Dans les fichiers de configuration et les variables d'environnement, c'est l'astuce pour faire passer des valeurs qui casseraient sinon le format : un DSN de base de données avec des points-virgules, un mot de passe avec des guillemets, une valeur avec un retour à la ligne. Dans les bases de données, un blob binaire peut vivre dans une colonne texte en base64 et survivre à tous les outils qui supposent du texte - au prix, tout de même, d'environ un tiers de taille en plus, donc dimensionnez vos colonnes en conséquence (ou demandez-vous pourquoi la valeur n'est tout simplement pas dans une colonne BLOB).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
static size_t real_length(const char *b64) {
size_t len = strlen(b64);
while (len > 0 && b64[len - 1] == '=') len--;
return len * 3 / 4;
}
int main(void) {
const char *b64 = getenv("API_KEY_B64");
if (b64 == NULL) {
printf("API_KEY_B64 is not set\n");
return 1;
}
size_t cap = strlen(b64);
unsigned char *out = malloc(cap);
int n = EVP_DecodeBlock(out, (const unsigned char *)b64, (int)cap);
if (n < 0) {
printf("API_KEY_B64 is not valid base64\n");
free(out);
return 1;
}
size_t real = real_length(b64);
printf("key is %zu bytes\n", real);
free(out);
return 0;
}
La mise en garde s'applique deux fois. D'abord, c'est une sécurité de format, pas un secret : dès qu'un développeur peut lire le fichier de configuration, il peut décoder la valeur en un seul appel, et la section sécurité du RFC relate de vrais incidents où des gens ont signalé un échange de protocole au support et « révélé le mot de passe par accident », parce que base64 déguise visuellement, il ne protège pas computationnellement. Ne stockez jamais un secret en base64 en appelant ça de l'encryption. Ensuite, validez au démarrage : une valeur d'environnement collée à moitié, c'est un -1 de l'appel strict, et une vérification d'une ligne transforme un échec cryptique trois heures plus tard en un message actionnable au boot.
Décoder depuis le shell
Tout le décodage n'a pas lieu à l'intérieur de votre programme. Les scripts CLI, les jobs cron et les one-liners décodent du base64 en permanence, et les développeurs C devraient connaître les deux outils qui existent déjà sur chaque boîte Linux. L'outil coreutils est le généraliste : base64 -d décode, -i le fait ignorer les caractères de saleté au lieu d'échouer, et -w fixe la colonne d'enveloppement (ce qui ne touche que l'encodage, pas le décodage) :
base64 -d < blob.b64 > blob.bin
base64 -d -i < messy.b64 > blob.bin
OpenSSL en fournit un à lui aussi, joignable via openssl base64 (un alias plus amical de openssl enc -base64) :
openssl base64 -d < blob.b64 > blob.bin
openssl base64 -d -A < blob.b64 > blob.bin
Le drapeau -A veut dire « une seule ligne » : encodez sans l'enveloppement de 64 caractères, et attendez-vous à ce que l'entrée soit aussi une seule ligne. Et voici un piège CLI qui vous coûtera une soirée si vous ne le lisez pas : le décodage base64 d'OpenSSL est orienté lignes, et un payload qui arrive sans aucun retour à la ligne se décode en rien du tout, silencieusement :
printf 'TQ==' | openssl base64 -d | wc -c # 0
printf 'TQ==\n' | openssl base64 -d | wc -c # 1
Le décodeur coreutils n'a pas cette attente, ce qui est une des raisons pour lesquelles c'est le défaut plus sûr pour le travail de collage. Une note de dialecte de plus : les systèmes dérivés de BSD (l'ancien macOS en particulier) épellaient historiquement le drapeau de décodage -D ; les versions modernes suivent la convention GNU de -d, donc consultez la page de man de la machine sur laquelle vous êtes vraiment.
Gros payloads, petite mémoire
Le décodage est la direction qui vous aide : la sortie fait trois quarts de la taille de l'entrée, donc la pression mémoire venue du base64 est rare. Pourtant, quand un fichier .b64 de plusieurs centaines de mégaoctets atterrit sur disque, le chemin en streaming d'avant est votre outil, et il est plus simple qu'il n'y paraît. Lisez le fichier encodé par morceaux, donnez chaque morceau à EVP_DecodeUpdate, et écrivez les octets décodés à leur arrivée. Le contexte garde entre les appels un à trois caractères de n'importe quel groupe incomplet, donc les frontières de morceaux peuvent tomber n'importe où - pas besoin de les aligner :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
EVP_DecodeInit(ctx);
FILE *in = fopen("huge.b64", "rb");
FILE *outf = fopen("huge.bin", "wb");
char inbuf[65536];
unsigned char outbuf[49152 + 4];
size_t got;
int ok = 1;
while (ok && (got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
int outl = 0;
int r = EVP_DecodeUpdate(ctx, outbuf, &outl,
(const unsigned char *)inbuf, (int)got);
if (r < 0) {
ok = 0;
} else if (outl > 0) {
fwrite(outbuf, 1, (size_t)outl, outf);
}
}
int tail = 0;
if (ok && EVP_DecodeFinal(ctx, outbuf, &tail) == 1 && tail > 0) {
fwrite(outbuf, 1, (size_t)tail, outf);
}
EVP_ENCODE_CTX_free(ctx);
fclose(in);
fclose(outf);
return ok ? 0 : 1;
}
La mémoire de pointe est deux buffers de l'ordre de quelques dizaines de kilooctets quelle que soit la taille du fichier, et un fichier corrompu échoue vite - EVP_DecodeUpdate renvoie -1 au morceau où est la casse, donc vous pouvez signaler un décalage au lieu de hausser les épaules. Une réserve de bibliothèque pour ce chemin : le décodeur d'APR-Util fonctionne sur des chaînes terminées par NUL avec une comptabilité de taille int (sa valeur de retour est un int et l'entrée est plafonnée juste sous 3 Go par une constante interne), donc il est hors course pour les fichiers multi-gigaoctets. Si vous avez besoin de rapporter la progression, comptez les octets que vous avez écrits - c'est votre position dans la sortie, et la position d'entrée est environ quatre tiers de celle-là.
Les pièges, tous spécifiques à C
Réunis au même endroit, les pièges qui sont spécifiques au fait de faire ça en C :
- Le one-shot bourré de zéros.
EVP_DecodeBlockrenvoie la longueur du quantum, pas la longueur des données.TQ==annonce trois octets mais n'en porte qu'un. Recalculez toujours la vraie longueur depuis les pads de fin, ou utilisez la paire en streaming. - Les octets décodés ne sont pas une chaîne. Le résultat peut contenir des octets NUL et n'est peut-être pas de l'UTF-8. Pas de
strlen, pas deprintf("%s"), pas de passage à des fonctions qui supposent du texte. Portez (pointeur, longueur) partout. - La taille du buffer est votre travail. C ne fera pas grandir votre buffer de sortie, et les décodeurs non plus - l'update d'OpenSSL écrit ce qu'il décode dans l'espace que vous lui avez donné. Dimensionnez à
in_len * 3 / 4 + 3(plus la surcharge d'enveloppement si l'entrée est enveloppée et que vous décodez avec un helper qui ne les supprime pas) et gardez une vérification de plafond dans chaque wrapper. - Les table-lookups en char signé. Si un jour vous écrivez vous-même un décodeur, le bug classique est d'utiliser l'octet d'entrée comme index dans un tableau de 256 entrées avec un simple
charsur une plateforme où char est signé : l'octet0xFFdevient-1et vous indexez en arrière à travers la mémoire. Indexez toujours avec des valeursunsigned charouunsigned. - Les silencieux sont les dangereux. APR-Util s'arrête au premier caractère invalide et ne dit rien ; GLib saute la saleté et ne dit rien. OpenSSL et Mbed TLS échouent fort. Si votre entrée n'est pas fiable, le silence de la bibliothèque est un bug dans votre programme, pas dans la bibliothèque.
- La ligne de commande mange les retours à la ligne.
openssl base64 -ddécode zéro octet si l'entrée n'a pas de retour à la ligne. Les pipelines shell qui suppriment les retours à la ligne de fin (tr -d '\n',xargs, sauvegardes d'éditeur sans dernier retour à la ligne) produiront une sortie vide sans aucune erreur. - int contre size_t. L'API one-shot d'OpenSSL prend une longueur
int, APR-Util utilise leintpartout, et les API Mbed TLS et GLib utilisentsize_t. L'arithmétique à longueurs mélangées entre elles est là où les avertissements signé/non signé cachent de vrais bugs - et c'est là que vit le plafond de 2 Go d'APR. - Les blancs ne sont pas uniformes. OpenSSL saute tous les blancs n'importe où ; Mbed TLS autorise CRLF/LF entre les groupes et les espaces juste avant une coupure, mais pas après une coupure ou au milieu d'une ligne ; les outils CLI varient. Un payload valide pour un décodeur peut être invalide pour un autre, et « ça marchait sur ma machine » veut dire le plus souvent « mon décodeur était plus paresseux ».
Les bonnes habitudes, réunies
Validez avant de faire confiance : une vérification de forme (caractères de l'alphabet, au plus deux pads de fin) attrape la saleté évidente avant tout décodage, mais seul un vrai décodage comprend la sémantique de base64, donc le décodeur strict a le dernier mot. Utilisez la paire en streaming d'OpenSSL quand vous avez besoin de longueurs honnêtes ou d'une entrée par morceaux, et le one-shot quand le payload est petit et que vous corrigez immédiatement sa longueur. Gardez les paires (pointeur, longueur) ensemble et ne laissez jamais un buffer décodé croiser une fonction de chaînes. Comparez les matériaux d'authentification avec CRYPTO_memcmp. Sniffez les magic bytes avant de croire un nom de fichier ou un type MIME déclaré. Et traitez base64 pour ce que c'est - un format d'emballage, une petite boîte pour des octets - pas comme un cadenas : rien dans ces 64 lettres ne rend vos données privées.
Une courte histoire de base64 en C
L'histoire commence avec le courrier. En 1990 et 1991, un groupe de cryptographes a esquissé Privacy Enhanced Mail, un système de courrier signé et chiffré, et il leur fallait un moyen de transporter du binaire à travers un réseau 7 bits. Leur réponse, standardisée en RFC 1421 en 1993, encodait les données six bits par caractère - « base 64 » - en lignes de 64 caractères, et l'implémentation était, bien sûr, du C. Vers le même temps, le web arrivait avec son propre MIME, le RFC 1521 (1993) puis le RFC 2045 (1996), qui gardait le même alphabet, relâchait la longueur de ligne à 76 et faisait de base64 le format de pièces jointes du jeune internet.
La bibliothèque standard de C a raté le bateau tout entier. La norme C89 a été publiée en 1990, trois ans avant le MIME, et le comité du langage n'a jamais ajouté de fonction base64 depuis - pas en C99, pas en C11, pas en C23 (la révision de 2024). L'écosystème a donc grandi autour des bibliothèques : OpenSSL porte les routines d'encodage/décodage EVP dans libcrypto depuis que quelqu'un lie OpenSSL pour le TLS, Mbed TLS (renommée depuis PolarSSL en 2015) a gardé une petite paire stricte pour les systèmes embarqués, APR-Util est sorti avec Apache quand le serveur a eu besoin de décoder ses propres en-têtes d'auth, et GLib a ajouté son trio pour le bureau. Les normes ont couru après les implémentations : le RFC 3548 en 2003 a rangé les vieilles définitions, et le RFC 4648 en 2006 (Base-N Encodings) a formalisé les alphabets, la variante URL-safe et les règles de sécurité sur lesquelles cet article s'appuie. Comme il se doit, la section 11 de ce RFC pointe vers une implémentation de référence en ISO C99 - le décodeur d'exemple de la norme lui-même est écrit en C, ce qui vous dit tout sur l'endroit où vit ce format.
Faits amusants, édition C
Quelques faits à la saveur de C qui sont simplement amusants à connaître :
- Le nom est des maths, pas du marketing : chaque caractère de sortie porte exactement six bits, et 2 à la puissance 6 fait 64. « Base64 », c'est la base, lue à voix haute.
- L'alphabet fait 65 caractères, pas 64 : les 64 symboles plus
=, que le RFC 4648 appelle « le 65e caractère en plus » utilisé pour une fonction de traitement spéciale. Le pad est un ouvrier, pas une lettre. - OpenSSL enveloppe la sortie encodée à 64 caractères (l'habitude PEM) tandis que coreutils enveloppe à 76 (l'habitude MIME). L'écart de 12 caractères, ce sont deux décennies d'histoire du courrier que vous pouvez voir dans la sortie de deux commandes sur la même machine.
- L'auteur de la commande
base64de GNU coreutils est Simon Josefsson - la même personne qui a écrit le RFC 4648. La norme et l'une de ses implémentations les plus utilisées partagent un auteur, et c'est ainsi qu'ils sont arrivés à se mettre d'accord sur chaque cas limite. - Mbed TLS fait ses table-lookups via des helpers en temps constant (
mbedtls_ct_base64_*), donc la vitesse de décodage ne révèle pas quels caractères il a vus. Un détail que vous ne remarquerez jamais et dont vous serez content qu'il existe. TQ==est le plus petit payload non trivial : un octet réel, deux pads. C'est le vecteur de test parfait - le décodeur one-shot d'OpenSSL renvoie trois octets pour lui, son décodeur en streaming renvoie un, Mbed TLS renvoie un, et GLib renvoie un. Quatre bibliothèques, deux réponses, et la différence, c'est le bourrage de zéros.- Les fonctions base64 d'APR sont les seules de cet article qui se soucient de l'EBCDIC, parce que httpd tourne encore sur des machines où les lettres ne sont pas de l'ASCII. La bibliothèque standard de C n'a jamais rencontré de mainframe ; APR si.
- Le payload vide est l'identité universelle : chaque bibliothèque encode et décode une entrée de longueur zéro en une sortie de longueur zéro, sans erreur. Si votre décodeur s'étouffe sur une chaîne vide, vous avez un bug, pas un format.
Passer du côté de l'encodeur
Voilà pour le côté décodeur, et c'est là que vit la plupart de la douleur, parce que le décodage, c'est là que vous rencontrez les données des autres : leurs choix de padding, leurs retours à la ligne, leurs octets corrompus, leurs tokens. La direction opposée - transformer des octets en chaîne base64 - est un animal plus calme, avec sa propre distribution de pièges : la mathématique exacte des buffers, la question de l'enveloppement des lignes, et la note de taille qui atterrit sur chaque expéditeur. L'encodage Base64 en C est couvert en profondeur dans l'article relié, lié depuis cette page, et il forme paire avec celui-ci de la même façon qu'un décodeur forme paire avec un encodeur : lisez les deux et plus aucune des deux directions ne vous surprendra.
Dernière mise à jour : 2026-09-08
Article associé : Encodage Base64 en C : un guide complet