Encodage Base64 en C : un guide complet
Vous avez des octets. Peut-être un JPEG lu depuis le disque, peut-être un token, peut-être un mot de passe qu'un client est sur le point de transmettre, peut-être les octets bruts d'un fichier qu'un pipeline attend dans un champ JSON. Et plus loin sur la route, il y a un canal qui ne parle que texte : une chaîne JSON, une URL, un corps d'e-mail, un fichier de configuration, une colonne de base de données qui fait semblant d'être du texte. C'est là que l'encodage base64 entre en scène : il réécrit chaque groupe de trois octets de données brutes sous forme de quatre caractères d'un alphabet de 64 lettres, si bien que le résultat est de l'ASCII pur qui survit à n'importe quel pipeline texte sur terre. La page d'accueil de ce site explique le format en détail ; cet article, c'est comment bien faire ce travail en C, où - comme d'habitude - le langage ne le fait pas à votre place.
Le chiffre unique à garder en tête : l'encodage fait grossir vos données. Trois octets deviennent quatre caractères, donc chaque payload quitte votre programme environ un tiers plus gros, plus un peu plus à chaque fois qu'on ajoute des retours à la ligne. C'est l'impôt, et il n'y a pas moyen de l'éviter - mais en C, l'impôt a sa ligne de facture, parce que c'est vous qui allouez le buffer de sortie et qu'il doit être pile assez grand. Faites bien le calcul une fois et tous les encodeurs de cet article deviennent prévisibles : ni débordement, ni sous-débordement, plus aucune inquiétude sur l'endroit où atterrira l'octet suivant. Ensuite, il y a la boîte à outils à choisir - OpenSSL, Mbed TLS, APR-Util, GLib - et le choix compte, parce que chacun enveloppe différemment, termine différemment et échoue différemment.
Quatre encodeurs, quatre personnalités
Les quatre bibliothèques encodent l'alphabet standard correctement et de façon identique - mêmes octets en entrée, mêmes caractères en sortie, toujours. Les différences sont dans l'emballage, et l'emballage, c'est là que se cachent les bugs d'interopérabilité. Voici le paysage :
| Bibliothèque | En-tête | Style de sortie | Mode d'échec |
|---|---|---|---|
| OpenSSL (libcrypto) | <openssl/evp.h> |
Pas de retours à la ligne ; écrit un terminateur NUL | Aucun en pratique (allocation uniquement) |
| Mbed TLS | <mbedtls/base64.h> |
Pas de retours à la ligne ; terminé par NUL | Code « buffer trop petit » avec la taille requise |
| APR-Util | <apr-1.0/apr_base64.h> |
Pas de retours à la ligne ; ajoute un NUL | Aucun - fiez-vous à la taille de votre buffer |
| GLib | <glib.h> |
Pas de retours à la ligne ; terminé par NUL, alloué sur le tas | Renvoie NULL (allocation uniquement) |
Notez ce qui manque au tableau : aucune d'elles n'enveloppe les lignes par défaut. C'est délibéré - le RFC 4648 dit que les implémentations ne doivent pas ajouter de retours à la ligne à moins que la spécification qui les entoure ne le demande explicitement - et c'est un soulagement, parce qu'un retour à la ligne égaré dans une chaîne JSON ou une URL est une erreur, pas une fonctionnalité. L'enveloppement existe pour l'e-mail et le PEM, et quand vous en avez besoin, vous le tirez du chemin en streaming d'OpenSSL ou vous faites l'enveloppement vous-même en cinq lignes (la section e-mail montre les deux). Pour choisir une bibliothèque : utilisez OpenSSL si vous la liez déjà, Mbed TLS pour les builds embarqués où chaque kilooctet est discuté, APR-Util dans l'écosystème Apache, et GLib quand le reste de votre programme est déjà en GLib. Installation : libssl-dev (Debian/Ubuntu) ou openssl-devel (Fedora/RHEL) ou brew install openssl (macOS) ; libmbedtls-dev pour Mbed TLS ; libaprutil1-dev plus libapr1-dev pour APR-Util ; glib2.0-dev pour GLib.
Faites le calcul avant d'allouer
Avant tout code, l'arithmétique, parce que C ne vous sauvera pas d'un buffer trop petit. Chaque groupe de trois octets en entrée produit exactement quatre caractères en sortie. Si la longueur d'entrée n'est pas un multiple de trois, le dernier groupe produit quand même quatre caractères et les cases inutilisées sont marquées de pads = : un octet en entrée devient quatre caractères avec deux pads, deux octets en entrée deviennent quatre caractères avec un pad. La longueur encodée exacte pour n octets est donc :
size_t encoded_chars(size_t n) {
return ((n + 2) / 3) * 4;
}
Pour 1000 octets, ça fait 1336 caractères ; pour 1 octet, c'est 4 ; pour 0, c'est 0. Deux ajustements en découlent. D'abord, OpenSSL et Mbed TLS ajoutent tous deux un terminateur NUL après les données (et Mbed TLS réserve l'espace pour le NUL quand vous demandez la taille), donc votre buffer veut un octet de plus : encoded_chars(n) + 1. Ensuite, si vous voulez une sortie enveloppée, ajoutez un retour à la ligne par ligne : l'encodeur en streaming d'OpenSSL émet une ligne de 64 caractères pour chaque 48 octets en entrée, donc la longueur enveloppée est encoded_chars(n) + (n + 47) / 48. Vérifiez avec 1000 octets : 1336 caractères plus 21 retours à la ligne font 1357, et c'est exactement ce que l'encodeur produit. Écrivez la formule une fois en fonction et utilisez-la partout ; c'est la différence entre « ça tient » et une corruption du tas à 3 h du matin.
size_t b64_buffer_size(size_t in_len) {
return ((in_len + 2) / 3) * 4 + 1; /* caractères + NUL */
}
size_t b64_buffer_size_wrapped(size_t in_len) {
return ((in_len + 2) / 3) * 4 + (in_len + 47) / 48 + 1;
}
OpenSSL : un bloc ou un flux continu
La fonction one-shot d'OpenSSL est le cheval de trait, et c'est la plus amicale du groupe :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *text = "Mane";
unsigned char out[32];
int n = EVP_EncodeBlock(out, (const unsigned char *)text,
(int)strlen(text));
printf("len=%d str=%s\n", n, (char *)out);
return 0;
}
Il écrit les caractères encodés dans out, ajoute un NUL après, et renvoie la longueur sans le NUL - donc l'impression avec %s est sûre et la longueur est disponible si vous en avez besoin. Le buffer de sortie doit contenir encoded_chars(n) + 1 octets. Il n'y a pas de chemin d'erreur à gérer : l'encodage ne peut pas échouer, parce que n'importe quel octet est une entrée légale, et la fonction n'a aucun concept de validation d'entrée sur lequel trébucher. La seule façon de se tromper, c'est de lui donner un buffer trop petit, et la section calcul est l'antidote.
La paire en streaming est pour quand les données sont grosses ou arrivent par morceaux. EVP_EncodeUpdate traite l'entrée par blocs de 48 octets et écrit 64 caractères plus un retour à la ligne (65 octets) par bloc complet, en gardant le reste dans le contexte jusqu'à de nouvelles données ou l'appel final :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
if (ctx == NULL) {
return 1;
}
EVP_EncodeInit(ctx);
unsigned char in[1000];
for (int i = 0; i < 1000; i++) {
in[i] = (unsigned char)(i % 251);
}
unsigned char out[1400]; /* 1336 caractères + 21 sauts de ligne + marge */
int outl = 0;
int total = 0;
EVP_EncodeUpdate(ctx, out + total, &outl, in, 1000);
total += outl;
EVP_EncodeFinal(ctx, out + total, &outl);
total += outl;
int nl = 0;
for (int i = 0; i < total; i++) {
if (out[i] == '\n') nl++;
}
printf("encoded 1000 bytes into %d chars, %d newlines\n",
total, nl);
EVP_ENCODE_CTX_free(ctx);
return 0;
}
La sortie de ce programme fait 1357 octets avec 21 retours à la ligne - la formule de la section calcul, rendue réelle. Deux notes pratiques. La note de version : depuis OpenSSL 1.1.0 (2016), le type de contexte est opaque, donc allouez avec EVP_ENCODE_CTX_new() et libérez avec EVP_ENCODE_CTX_free() ; l'ancien pattern pile EVP_ENCODE_CTX ctx; que vous trouverez dans les tutoriels visant 1.0.2 et avant ne compile pas contre les en-têtes modernes, OpenSSL 3.x compris. Et la note de conception : comme seuls des blocs complets de 48 octets sortent de EVP_EncodeUpdate, le pipeline par morceaux le plus propre lui donne des multiples de 48 - alors chaque ligne que la fonction écrit est une ligne finie, et EVP_EncodeFinal seul décide comment la queue est enveloppée. Si votre entrée arrive en tailles arbitraires (une lecture réseau), le contexte gère quand même l'alignement pour vous ; l'habitude des multiples de 48 n'est que ce qui rend la sortie prévisible.
Mbed TLS : demander, puis encoder, obtenir une chaîne
L'encodeur de Mbed TLS a le contrat le plus propre du groupe, construit autour d'une requête de taille que vous pouvez appeler avec une destination NULL :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/base64.h>
int main(void) {
const char *text = "Mane";
size_t slen = strlen(text);
size_t needed = 0;
int rc = mbedtls_base64_encode(NULL, 0, &needed,
(const unsigned char *)text, slen);
if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
printf("size query failed: %d\n", rc);
return 1;
}
printf("needs %zu bytes\n", needed);
unsigned char *out = malloc(needed);
size_t olen = 0;
rc = mbedtls_base64_encode(out, needed, &olen,
(const unsigned char *)text, slen);
if (rc != 0) {
printf("encode failed: %d\n", rc);
free(out);
return 1;
}
printf("olen=%zu str=%s\n", olen, out);
free(out);
return 0;
}
Lisez les détails attentivement, parce qu'ils sont un cours magistral d'API amicale. La requête de taille rapporte needed comme les caractères encodés plus un pour le NUL - pour « Mane » ça fait 8 plus 1, donc 9 - et elle se signale avec le code « buffer trop petit » (MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL, qui est -0x002A) parce qu'une destination NULL est, par définition, trop petite. L'appel réel écrit alors les caractères et le NUL, et *olen revient à 8 - la longueur sans le terminateur - donc le buffer est déjà une chaîne C imprimable. Si vous lui donnez un buffer court d'un octet, vous recevez le même code « trop petit » avec la taille requise dans *olen, donc l'échec vous dit exactement de combien il vous en manquait. Une note de plus : la bibliothèque fait ses table-lookups via des helpers en temps constant, un petit signe de soin que vous ne verrez pas dans la plupart des encodeurs.
APR-Util et GLib : les deux autres
L'encodeur d'APR-Util est une paire de fonctions simple, à longueurs int :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <apr-1.0/apr_base64.h>
int main(void) {
const char *text = "Mane";
int needed = apr_base64_encode_len((int)strlen(text));
char *out = malloc((size_t)needed);
int n = apr_base64_encode(out, text, (int)strlen(text));
printf("n=%d (includes NUL) str=%s\n", n, out);
free(out);
return 0;
}
Ici, apr_base64_encode_len() et la valeur de retour comptent tous deux le NUL, donc n est un de plus que le nombre de caractères - une différence de comptabilité par rapport à OpenSSL et Mbed TLS qui a produit des bugs d'off-by-one dans plus d'une base de code. La même limite de longueur 32 bits s'applique : pour des valeurs proches de 2 Go ou au-dessus, ce n'est pas l'outil. Il y a aussi apr_base64_encode_binary(), qui sur les machines EBCDIC saute la conversion EBCDIC-vers-ASCII de l'entrée - sur les mainframes où cette conversion se ferait sinon, et sans aucun effet partout ailleurs. Cette paire, la variante binaire et les fonctions de décodage correspondantes sont en fait toute la surface base64 qu'apr-util expose : il n'y a ni variante allouée depuis le pool ni variante en streaming, donc le pattern malloc ci-dessus est le seul pattern.
L'encodeur de GLib est le style alloué sur le tas - vous obtenez une chaîne terminée par NUL et une responsabilité :
#include <stdio.h>
#include <glib.h>
int main(void) {
const char *text = "Mane";
gchar *enc = g_base64_encode((const guchar *)text, strlen(text));
printf("%s\n", enc);
g_free(enc);
return 0;
}
Pas d'enveloppement, terminé par NUL, libérez avec g_free - c'est l'annotation G_GNUC_MALLOC sur le prototype qui le dit aux analyseurs statiques. Quand vous voulez vraiment des retours à la ligne, la paire incrémentale est l'outil : g_base64_encode_step() prend un entier d'état et un drapeau break_lines et vous dit combien d'octets de sortie il a écrit, et g_base64_encode_close() termine le dernier groupe partiel. C'est la même forme de machine à états que la paire en streaming d'OpenSSL, simplement avec le style de paramètres de GLib.
Le base64 URL-safe fait main
Les quatre bibliothèques ci-dessus parlent toutes l'alphabet standard : A-Z, a-z, 0-9, plus et slash. Le web, lui, parle de plus en plus le second dialecte de la section 5 du RFC 4648, appelé base64url : le même encodage avec + remplacé par - et / remplacé par _, et le padding de = de fin supprimé quand la longueur est connue. Les JSON Web Tokens, les paramètres de state OAuth et d'innombrables identifiants d'API l'utilisent, parce que + et / sont tous deux dangereux dans les URLs, tandis que - et _ sont des caractères non réservés qui passent sans friction. Comme aucune bibliothèque C n'émet ce dialecte nativement, vous le fabriquez vous-même - et ce sont deux petites modifications, parce que vous avez déjà un encodeur à alphabet standard :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
size_t i = 0;
for (const char *p = std_b64; *p && *p != '='; p++) {
char c = *p;
if (c == '+') c = '-';
if (c == '/') c = '_';
out[i++] = c;
}
out[i] = '\0'; /* padding délibérément supprimé */
}
L'usage tient en deux étapes - encoder en standard, puis traduire :
unsigned char enc[32];
EVP_EncodeBlock(enc, (const unsigned char *)"hi>there", 8);
char url_safe[32];
to_base64url((const char *)enc, url_safe, sizeof(url_safe));
printf("%s\n", url_safe); /* aGk-dGhlcmU */
Deux mises en garde. La boucle s'arrête au premier =, et c'est ce qui supprime le padding - ne « corrigez » pas ça, supprimer le padding est le but (le destinataire qui en a besoin peut le rajouter depuis la longueur). Et dimensionnez out pour la longueur encodée complète, pas moins : la traduction est caractère par caractère jusqu'aux pads, donc la capacité que vous avez déjà allouée pour la forme standard est pile la bonne. Une note honnête pour l'interopérabilité : si vos données ne contiennent par hasard aucun octet qui se mappe en + ou /, les formes standard et URL-safe sont identiques et rien ne se plaindra jamais d'une confusion - le bug ne surface que quand les données finissent par en contenir un. Traitez le dialecte comme une propriété du canal (URLs, tokens), pas des données.
Texte et jeux de caractères : l'UTF-8, c'est juste des octets
Une question qui surprend les nouveaux en C : qu'advient-il du texte accentué, des emoji, des caractères CJK ? La réponse est le fait le plus libérateur de cet article - il n'a rien à se passer. Le base64 opère sur des octets, et C est un langage d'octets. Si votre texte est en UTF-8 (ce qui, en 2026, est probablement le cas), l'encodage UTF-8 de « café » fait cinq octets - 63 61 66 c3 a9 - et base64 encode ces cinq octets exactement comme n'importe quels autres cinq octets, produisant Y2Fmw6k=. Ni paramètre de charset, ni BOM, ni étape de conversion, ni appel de bibliothèque. Le codec ne sait pas et ne se soucie pas de ce que les octets veulent dire ; c'est le design tout entier.
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *utf8 = "caf\303\251"; /* café en UTF-8 */
unsigned char enc[32];
int n = EVP_EncodeBlock(enc, (const unsigned char *)utf8,
(int)strlen(utf8));
printf("%.*s\n", n, (char *)enc); /* Y2Fmw6k= */
return 0;
}
Deux pièges se tiennent aux bords de cette section. Le premier est wchar_t : si vos données sont arrivées en caractères larges, vous devez d'abord les convertir en séquence d'octets (sur Linux, UTF-8, via wcstombs() ou votre mécanique de locale) avant d'encoder - le base64 d'un tableau de wchar_t, c'est l'encodage d'une représentation interne, pas du texte, et ça différera d'une plateforme à l'autre. Le second est l'encodage de la source : le littéral de chaîne dans votre fichier C est encodé dans l'encodage du fichier source (UTF-8 dans n'importe quel projet moderne), donc écrire "café" directement fonctionne tant que le fichier est vraiment en UTF-8 et que votre compilateur est prévenu (c'est le cas par défaut dans les toolchains modernes). Encodez les octets que vous voulez envoyer, et laissez le destinataire se débrouiller avec ce que les octets veulent dire.
Images : du buffer à la chaîne
Le boulot d'encodage « réel » le plus courant en C web : un fichier binaire - un JPEG, un PNG, une icône - doit voyager par un canal texte, alors il devient une chaîne base64. La recette, c'est lire le fichier dans un buffer, dimensionner la sortie avec la formule, encoder, et avancer. La moitié de lecture du fichier mérite de l'attention, parce que c'est là que les programmes C cassent vraiment :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("photo.png", "rb");
if (f == NULL) {
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char *data = malloc((size_t)size);
size_t got = fread(data, 1, (size_t)size, f);
fclose(f);
size_t out_cap = ((got + 2) / 3) * 4 + 1;
unsigned char *enc = malloc(out_cap);
int n = EVP_EncodeBlock(enc, data, (int)got);
printf("png of %zu bytes becomes %d base64 chars\n", got, n);
free(data);
free(enc);
return 0;
}
Notes : rb pour la lecture binaire - non négociable sur n'importe quelle plateforme, parce que le mode texte peut traduire des octets et changer got ; la paire fseek/ftell pour la dimension (pour les pipes et sockets sans seek, lisez plutôt dans un buffer qui grandit) ; et got plutôt que size pour l'encodage, parce qu'une lecture courte est une vraie possibilité. Une image de 1 Mo devient environ 1,33 Mo de texte - c'est l'impôt, facturé à l'avance, et c'est pourquoi un payload d'image base64-dans-JSON devrait vous faire marquer un temps d'arrêt et vous demander si un vrai upload de fichier aurait été moins cher.
Fichiers et l'habitude du .b64
L'autre face du boulot des images : vous devez écrire la forme base64 d'un fichier sur disque - un fichier compagnon .b64, une sauvegarde d'un binaire dans un stockage sûr pour le texte, une pièce jointe pour un mailer. Même calcul, auteur différent. L'habitude à adopter, c'est d'écrire la sortie texte avec des retours à la ligne explicites à une longueur que le côté receveur attend - 76 pour l'e-mail, 64 pour les consommateurs de style PEM, ou aucun du tout si le receveur est votre propre code :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
FILE *f = fopen("data.bin", "rb");
if (f == NULL) {
return 1;
}
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char *data = malloc((size_t)size);
size_t got = fread(data, 1, (size_t)size, f);
fclose(f);
unsigned char *enc = malloc(((got + 2) / 3) * 4 + 1);
int n = EVP_EncodeBlock(enc, data, (int)got);
FILE *out = fopen("data.b64", "w");
for (int i = 0; i < n; i += 76) {
int chunk = i + 76 < n ? i + 76 : n;
fwrite(enc + i, 1, (size_t)(chunk - i), out);
fputc('\n', out);
}
fclose(out);
free(data);
free(enc);
return 0;
}
La boucle écrit des lignes de 76 caractères et une ligne finale plus courte ; un décodeur qui saute les blancs (tous les sérieux le font) ne se souciera pas de la longueur des lignes du tout, et c'est pourquoi c'est le receveur qu'il faut consulter, pas votre goût. Gardez le fichier de sortie en mode texte (w) côté écriture si vous voulez les fins de ligne de la plateforme, ou wb si le receveur compte les caractères strictement - et s'il compte, il veut exactement ce que vous avez promis : 76 caractères plus un retour à la ligne, rien d'autre. C'est cette promesse, pas les octets, qui fait d'un fichier .b64 un format.
Les data URIs : l'embarquement pour de vrai
Les data URIs (RFC 2397) sont l'autre face de l'arrivage favori de l'article de décodage : au lieu de recevoir data:image/png;base64,..., vous en construisez un. La forme, c'est data:, le type média, ;base64, une virgule, le payload - et le construire en C, c'est un snprintf après l'encodage :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
/* la signature PNG de 8 octets */
const unsigned char png_sig[8] =
{ 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n' };
unsigned char enc[32];
int n = EVP_EncodeBlock(enc, png_sig, 8);
char uri[96];
snprintf(uri, sizeof(uri), "data:image/png;base64,%.*s",
n, (char *)enc);
printf("%s\n", uri);
return 0;
}
Trois notes de conception. Le type média que vous mettez dans l'URI est une affirmation dont vous êtes responsable - sniffez d'abord les magic bytes du vrai fichier, sinon un data:image/png qui porte un JPEG va embrouiller chaque consommateur d'une façon différente. Le drapeau ;base64 est obligatoire quand le payload est en base64 ; omittez-le et le payload doit être à la place du texte percent-encodé, ce qui est un format tout à fait différent. Et le conseil du RFC lui-même est que les data URIs sont pour les valeurs courtes : embarquer un logo de 5 Mo inline dans une page HTML fonctionne, mais c'est une odeur de design qu'une vraie URL d'actif corrigerait. La même construction apparaît tout le temps dans les API JSON où un client veut un avatar dans la même requête que les données du formulaire - encoder, préfixer, envoyer.
HTTP et JSON : des payloads qui survivent
La plus grande raison moderne d'encoder en C, c'est le JSON. Une chaîne JSON est une séquence de caractères avec des règles d'échappement, et les octets bruts n'y tiennent pas : un NUL au milieu d'un littéral de chaîne est un problème C, un retour à la ligne littéral dans une chaîne JSON est du JSON invalide, et des octets arbitraires ont besoin d'une histoire d'échappement définie. Le base64 contourne tout le problème en ne produisant que des caractères que le JSON n'a jamais besoin d'échapper - les 64 caractères de l'alphabet plus, dans le dialecte standard, =, et aucun d'eux n'est une quote ni un antislash. Le binaire entre comme chaîne et sort de l'autre côté exactement comme il est entré :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
unsigned char enc[64];
int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello", 5);
char json[160];
snprintf(json, sizeof(json),
"{\"avatar\": \"%.*s\"}", n, (char *)enc);
printf("%s\n", json);
return 0;
}
Cela imprime {"avatar": "aGVsbG8="} - un objet JSON complet et valide, aucun mécanisme d'échappement impliqué, et la précision %.*s garde la longueur exacte même si un jour vous passez à un encodeur qui ne termine pas par NUL. Le coût honnête, c'est la taille : chaque octet que vous expédiez comme chaîne JSON vous coûte 4/3 d'octet d'air plus le nom du champ et les quotes, donc un binaire de 10 Ko devient une chaîne de 13,3 Ko dans le JSON. Pour des petits blobs occasionnels (icônes, miniatures, signatures, tokens), c'est un prix tout à fait correct ; pour un upload de 500 Mo, c'est une architecture que vous regretterez, et une vraie mise en fichier est l'outil pour ce boulot. Une ligne en vaut aussi la peine : le + et le / de l'alphabet standard sont sûrs dans une chaîne JSON, mais si la même chaîne voyage ensuite dans une requête d'URL, ils ne le sont plus - c'est le travail de la section URL-safe.
Les JWT : trois parties, un alphabet
Le consommateur fleuron du base64url en C est le JSON Web Token. Un JWT compact selon le RFC 7519, ce sont trois parties encodées en base64url jointes par des points - en-tête, payload, signature - et en construire un est un exercice agréable, parce que chaque pièce est une fonction que vous avez déjà : encoder en standard, traduire en URL-safe, signer, répéter. Voici un token HS256 construit avec l'HMAC d'OpenSSL :
#include <stdio.h>
#include <string.h>
#include <openssl/hmac.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
size_t i = 0;
for (const char *p = std_b64; *p && *p != '='; p++) {
char c = *p;
if (c == '+') c = '-';
if (c == '/') c = '_';
out[i++] = c;
}
out[i] = '\0';
}
int main(void) {
const char *secret = "my-hmac-secret-key";
const char *header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
const char *payload = "{\"sub\":\"114365\",\"name\":\"Alice\"}";
unsigned char hb[64], pb[64];
EVP_EncodeBlock(hb, (const unsigned char *)header,
(int)strlen(header));
EVP_EncodeBlock(pb, (const unsigned char *)payload,
(int)strlen(payload));
char hu[64], pu[64];
to_base64url((const char *)hb, hu, sizeof(hu));
to_base64url((const char *)pb, pu, sizeof(pu));
char signing_input[256];
snprintf(signing_input, sizeof(signing_input), "%s.%s", hu, pu);
unsigned char mac[EVP_MAX_MD_SIZE];
unsigned int mac_len = 0;
HMAC(EVP_sha256(), secret, (int)strlen(secret),
(const unsigned char *)signing_input,
(size_t)strlen(signing_input), mac, &mac_len);
unsigned char mb[64];
EVP_EncodeBlock(mb, mac, (int)mac_len);
char mu[128];
to_base64url((const char *)mb, mu, sizeof(mu));
printf("%s.%s.%s\n", hu, pu, mu);
return 0;
}
Deux choses que la structure enseigne. D'abord, l'entrée de signature, ce sont les deux parties URL-safe jointes par un point - exactement les octets que le receveur verra - donc la traduction en base64url doit se faire avant la signature, pas après ; signez la forme à alphabet standard et la vérification du receveur échoue, ce qui est un bug qui compile, tourne et ressemble à un désaccord de clé. Ensuite, l'en-tête et le payload sont du JSON brut dans une enveloppe base64 : n'importe qui peut les lire, et c'est le design. Un token est un billet signé, pas une enveloppe scellée - donc ne mettez dedans rien que vous ne voudriez pas voir lu par un utilisateur qui intercepte, et ne mettez jamais, jamais, un mot de passe dans le payload d'un JWT « parce qu'il est encodé ». La partie base64 de ce boulot est petite et ennuyeuse, et c'est le plus grand compliment qu'on puisse faire à une implémentation de JWT.
L'auth Basic HTTP : construire le token
L'en-tête d'authentification le plus ancien est aussi le boulot base64 le plus simple : username:password, encodé à alphabet standard, après le mot Basic. Le construire en C tient en deux lignes, et la seule subtilité, c'est que le mot de passe peut contenir un deux-points (et que la coupure du côté receveur doit se faire au premier) :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *user = "alice";
const char *pass = "s3cr3t";
char creds[128];
snprintf(creds, sizeof(creds), "%s:%s", user, pass);
unsigned char enc[160];
int n = EVP_EncodeBlock(enc, (const unsigned char *)creds,
(int)strlen(creds));
printf("Authorization: Basic %.*s\n", n, (char *)enc);
return 0;
}
Cela imprime Authorization: Basic YWxpY2U6czNjcjN0. L'avertissement du RFC s'applique aussi côté expéditeur : c'est un encodage, pas une protection. Sur une connexion HTTP en clair, l'identifiant est à une base64 -d de n'importe qui sur le fil, donc l'auth Basic est une habitude réservée au HTTPS. (Les alternatives modernes - les tokens bearer, le mTLS - réutilisent toutes le même mécanisme : assembler une chaîne, l'encoder, la mettre dans un en-tête. Le base64 est la façon dont HTTP fait passer des données structurées à travers les en-têtes texte depuis que le protocole a des en-têtes.)
E-mails et PEM : là où vit l'enveloppement
L'e-mail est la raison pour laquelle l'enveloppement existe. Le SMTP limite la longueur des lignes, donc le MIME a plafonné les lignes encodées à 76 caractères (le PEM, son ancêtre, à 64), et tous les systèmes de courrier respectent ce plafond depuis trente ans. Si votre programme C produit du base64 pour un corps d'e-mail ou une pièce jointe, l'enveloppement n'est pas une cosmétique optionnelle - une ligne non enveloppée de 200 Ko sera rejetée ou cabossée par une partie de l'infrastructure de courrier. L'encodeur en streaming d'OpenSSL vous donne une sortie enveloppée gratuitement (à sa longueur d'origine de 64 caractères), et quand vous voulez pile 76, envelopper un résultat one-shot tient en une boucle de cinq lignes :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
unsigned char enc[64];
int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello world", 11);
for (int i = 0; i < n; i += 76) {
int chunk = i + 76 < n ? i + 76 : n;
printf("%.*s\r\n", chunk, (char *)enc + i);
}
return 0;
}
Notez le \r\n : l'e-mail veut des fins de ligne CRLF, et si le texte encodé est l'une de plusieurs parties MIME, tout ce qui entoure le bloc base64 suit les mêmes règles - longueurs de lignes, fins CRLF, sans exception. Les fichiers PEM (le format de la plupart des clés et certificats) utilisent la même idée avec des lignes de 64 caractères entre les marqueurs -----BEGIN et -----END, et les outils d'OpenSSL s'attendent à voir cette armure quand vous re-sauvez une clé - donc si votre programme touche au PEM, enveloppez à 64 et gardez les étiquettes. Partout ailleurs - JSON, URLs, API, bases de données - la règle du RFC s'applique et vous n'enveloppez pas du tout.
Faire passer des valeurs à travers les configs et les colonnes
Le cas d'usage discret : les valeurs qui casseraient un format texte s'emballent en base64 pour ne pas le faire. Un DSN de base de données avec des points-virgules, un mot de passe avec des quotes, un token avec un retour à la ligne - l'opérateur les encode une fois et le fichier de configuration ne voit jamais les caractères à problème :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
const char *dsn = "pg:host=db;password=qu\"ote";
unsigned char enc[128];
int n = EVP_EncodeBlock(enc, (const unsigned char *)dsn,
(int)strlen(dsn));
printf("DB_DSN_B64=%.*s\n", n, (char *)enc);
return 0;
}
Le programme imprime la ligne exacte à coller dans un fichier .env, et le code C qui la lit plus tard est un getenv plus un décodage. Trois mises en garde honnêtes, toutes sur ce que ce n'est pas. Ce n'est pas de l'encryption : n'importe qui peut lire le fichier de configuration et peut décoder la valeur en un seul appel, donc n'emballiez jamais un secret en base64 en l'appelant protégé. Ce n'est pas de l'échappement : si le format a besoin que la structure soit préservée, un vrai encodage (percent-encoding pour les URLs, échappement JSON pour le JSON) est l'outil correct, et base64 est pour les valeurs que ces formats ne peuvent pas exprimer - les binaires. Et ça coûte de la taille : une valeur stockée dans une colonne TEXT de base de données en base64 occupe environ 33 pour cent de plus que l'original, ce qui va bien pour les tokens et est un vrai chiffre pour les colonnes de fichiers (c'est à quoi servent les colonnes BLOB).
Encoder depuis le shell
Avant d'aller chercher une invocation cc, rappelez-vous que les deux outils standard encodent, et vite. coreutils est l'instrument généraliste : base64 encode par défaut avec un enveloppement de 76 caractères, -w change la colonne, et -w 0 désactive entièrement l'enveloppement :
base64 photo.png > photo.b64
base64 -w 0 photo.png > photo-oneline.b64
cat note.txt | base64 -w 0
L'outil d'OpenSSL est le même boulot avec un emballage de lignée TLS : openssl base64 (l'alias amical de openssl enc -base64) enveloppe à 64 caractères et -A le passe à une seule ligne :
openssl base64 < photo.png > photo.b64
openssl base64 -A < photo.png > photo-oneline.b64
Pourquoi se soucier de la différence d'enveloppement ? Parce que les sorties par défaut des deux outils ne sont pas interchangeables si un parser en aval compte les caractères - 76 par ligne contre 64 par ligne, c'est une différence visible dans le fichier, et un parser qui supprime les blancs s'en fiche tandis qu'un qui valide la longueur des lignes s'en soucie absolument. Quand votre programme C est le producteur et le shell le consommateur (ou l'inverse), mettez-vous d'accord d'abord sur l'enveloppement. Une note de dialecte pour les systèmes à saveur BSD : le drapeau de décodage y était historiquement -D, et les versions macOS plus anciennes s'en souviennent encore ; le côté encodage est base64 partout, et c'est de toute façon la seule direction dont cette section parle.
Le streaming du gros
Encoder un fichier de plusieurs gigaoctets dans un seul malloc, c'est un problème mémoire que vous n'aviez pas besoin d'avoir. Le chemin en streaming existe exactement pour ça, et la discipline par blocs d'OpenSSL rend le code presque trivial : donnez à EVP_EncodeUpdate autant que le fichier vous en donne, laissez-le garder le reste de chaque bloc partiel de 48 octets dans le contexte, et écrivez les 65 octets de sortie de chaque bloc directement dans le fichier de destination. La mémoire de pointe, c'est vos deux buffers - quelques dizaines de kilooctets - quelle que soit la taille du fichier :
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
if (ctx == NULL) {
return 1;
}
EVP_EncodeInit(ctx);
FILE *in = fopen("video.mp4", "rb");
FILE *out = fopen("video.b64", "w");
if (in == NULL || out == NULL) {
return 1;
}
char inbuf[48 * 1024]; /* un multiple de 48: lignes propres */
unsigned char outbuf[1024 * 65 + 65]; /* 65 octets de sortie par bloc de 48 octets, plus une marge */
size_t got;
while ((got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
int outl = 0;
EVP_EncodeUpdate(ctx, outbuf, &outl,
(const unsigned char *)inbuf, (int)got);
fwrite(outbuf, 1, (size_t)outl, out);
}
unsigned char tail[66];
int outl = 0;
EVP_EncodeFinal(ctx, tail, &outl);
fwrite(tail, 1, (size_t)outl, out);
EVP_ENCODE_CTX_free(ctx);
fclose(in);
fclose(out);
return 0;
}
Deux détails de conception font du travail dans cette boucle. Le buffer d'entrée est un multiple de 48 octets, donc chaque appel tend à l'encodeur des blocs complets et chaque ligne qu'il écrit est une ligne de 64 caractères finie ; l'appel final enveloppe alors la vraie queue. Si votre entrée arrive en tailles arbitraires (un socket, un disque lent), le contexte absorbe quand même correctement le désalignement - le choix du multiple de 48 concerne la prévisibilité de la sortie, pas la justesse. Le second détail, c'est la taille du buffer de sortie : 65 octets pour 48 octets d'entrée plus la marge du bloc final (66), c'est la formule enveloppée de la section calcul appliquée par morceau. Le rapport de progression tient en une ligne - comptez les octets écrits dans out par rapport au total que vous avez calculé depuis la taille du fichier - et comme l'encodage fait grossir les données, le fichier de sortie atterrira environ 33 pour cent plus gros que l'entrée : facturez le disque à l'avance.
Les arêtes vives de l'encodage
Les pièges, réunis, tous de forme C :
- L'off-by-one, dans les deux sens. OpenSSL renvoie la longueur sans son NUL ; la requête de taille de Mbed TLS inclut l'espace pour le NUL ; APR compte le NUL dans ses longueurs. Trois bibliothèques, trois conventions de comptabilité. Écrivez la fonction de taille de buffer une fois (la section calcul) et arrêtez de faire l'arithmétique dans votre tête au point d'appel.
- Le NUL est un octet que vous devez payer. Chaque encodeur de cet article veut un octet de plus dans la sortie pour le terminateur, et de même pour n'importe quel ajout à un buffer que vous écrivez à la main. Un buffer dimensionné pile à
encoded_chars(n)est court d'un octet au moment où quelqu'un veut queprintf("%s")marche. - L'encodage ne peut pas échouer, donc vous ne devez pas le laisser déborder. Il n'y a pas de code d'erreur qui attrapera un buffer trop petit - l'encodeur écrira joyeusement au-delà de la fin. Le modèle d'échec de l'encodage base64 en C est entièrement le vôtre : dimensionnez bien, sinon il corrompt la mémoire sans aucun diagnostic.
- Le double encodage est le bug silencieux classique. Une valeur qui est déjà du base64, passée une seconde fois dans l'encodeur, produit une chaîne base64 parfaitement valide qui se décode en une chaîne base64 au lieu des données. Le symptôme - « ça se décode, mais pas en la bonne chose » - prend une après-midi à trouver. Si une valeur arrive « pré-encodée », vérifiez que sa longueur est un multiple de quatre et qu'elle ne contient que des caractères de l'alphabet avant de supposer que ce sont des données brutes ; si elle est encodée, sautez l'encodage.
- Le signe plus dans les URLs. Une sortie à alphabet standard mise dans une chaîne de requête arrive avec le
+transformé en espace avant que votre serveur ne parse le formulaire - le+est un espace dans l'encodage percent/form. Les tokens et identifiants qui voyagent dans les URLs veulent le dialecte URL-safe, point final. - Le mode texte au mauvais bout du tuyau. Lire un fichier binaire en mode texte peut traduire des octets (sur certaines plateformes) et changer votre longueur ; écrire du base64 enveloppé avec la mauvaise convention de fins de ligne casse les receveurs qui comptent les caractères.
rbpour l'entrée binaire,\r\nou\nexplicites là où une spécification l'exige, et ne laissez jamais le runtime C décider silencieusement de vos fins de ligne. - Le débordement d'int dans le calcul de taille.
((n + 2) / 3) * 4en arithmétiqueintdéborde pour des entrées au-dessus d'environ 1,5 Go, produisant une petite « taille requise » positive et un smash du tas. Faites le calcul ensize_t(ouuint64_t), ce qui est aussi pourquoi l'API à base d'int d'APR a un plafond de 2 Go que vous ne pouvez pas éliminer par la conception. - L'enveloppement là où le receveur ne l'attend pas. Le RFC 4648 dit : pas de retours à la ligne à moins que la spécification environnante ne le demande. Un retour à la ligne dans la valeur d'une chaîne JSON est invalide ; dans une URL, c'est une requête différente. Enveloppez pour le courrier, enveloppez pour le PEM, et nulle part ailleurs.
La checklist courte
Calculez la taille du buffer avec la formule, pas avec un coup de tête, et gardez une seule fonction de taille pour toute la base de code. Gardez (pointeur, longueur) ensemble même quand le buffer est terminé par NUL, parce que la longueur est le contrat et que le NUL est une commodité. Choisissez le dialecte selon le canal : standard pour le JSON et les corps, URL-safe pour les URLs et les tokens, enveloppé pour le courrier et l'armure, non enveloppé partout ailleurs. Vérifiez les magic bytes avant de réclamer un type MIME dans une data URI. N'utilisez jamais base64 comme encryption, comme substitut du percent-encoding, ou comme endroit pour cacher un secret - c'est une boîte, pas un cadenas. Et quand les données sont grosses, envoyez-les en streaming : les encodeurs par blocs ont été conçus exactement pour ça, et la mémoire constante est tout l'objectif.
Histoire : comment l'emballage s'est standardisé
L'histoire de l'encodeur est l'histoire des longueurs de lignes. Le premier base64 était un programme C du début des années 1990. Le Privacy-Enhanced Mail (RFC 1421, 1993) devait transporter du binaire à travers le courrier 7 bits, et ses auteurs ont choisi six bits par caractère en lignes de 64 caractères - le 64 est un reliquat de la tolérance du SMTP à la longueur des lignes, et le code C faisait l'emballage table-lookup par table-lookup. Quand le MIME a standardisé le même alphabet pour le web (RFC 1521 en 1993, RFC 2045 en 1996), il a relâché la ligne à 76 caractères, et le monde a porté deux habitudes - 64 et 76 - qui se réclamaient toutes deux « la » longueur de ligne base64. Les encodeurs ont suivi : le chemin en streaming d'OpenSSL a gardé 64 (son héritage PEM), l'outil coreutils a choisi 76 (son héritage MIME), et les deux outils sur la même machine ne sont toujours pas d'accord sur l'endroit où mettre les retours à la ligne. La norme a enfin pris position en 2006 : le RFC 4648 dit que les implémentations ne doivent pas ajouter de retours à la ligne du tout, à moins que la spécification de référence ne le leur ordonne explicitement, et c'est pourquoi chaque bibliothèque de cet article a la sortie non enveloppée par défaut et pourquoi l'enveloppement est devenu une fonctionnalité opt-in pour l'e-mail et l'armure. L'alphabet lui-même, les règles de padding et la règle de canonicité « les bits de padding doivent être zéro » datent de ces RFCs PEM et MIME plus anciens, et le 4648 les réénonce comme les règles canoniques de la famille. Et la section 11 du RFC pointe vers une implémentation de référence - un programme en ISO C99, hébergé en externe parce que le code lui-même « ne pouvait pas être inclus dans ce RFC pour des raisons procédurales » - un autre rappel que dans ce format, C n'est pas un citoyen de second rang. La bibliothèque standard de C, pour sa part, n'a jamais rattrapé le rythme : C89 s'est figée en 1990, avant que rien de tout ça n'existe, et C23 en 2024 sort toujours sans fonction base64. Donc les bibliothèques que vous liez sont la norme, et le choix entre elles est une décision de design petite mais réelle - ce dont cet article a parlé.
Petits faits étranges
Quelques faits simplement amusants, tous sur le côté emballage en C :
- Le « 64 », c'est la base : chaque caractère de sortie fait six bits, et 2 à la puissance 6 fait 64. Le format nomme son alphabet comme C nomme ses entiers - par ce que le nombre est réellement.
- Le bloc de 48 octets de l'encodeur en streaming d'OpenSSL n'est pas une comptabilité arbitraire : 48 octets d'entrée, c'est exactement 16 groupes de 3, et 64 caractères de sortie, c'est exactement 16 groupes de 4. Les deux nombres sont des multiples de 16, c'est la rondeur qui rend le matériel et les lignes de cache heureux - ou au moins les humains qui lisent le code.
- Un octet d'entrée s'encode en quatre caractères, dont deux sont des
=. Le payload non vide le plus petit possible, c'est 50 pour cent de padding - l'encodage le plus gaspilleur du format, et celui que toutes les suites de tests utilisent, parce que c'est si facile de l'écrire faux. - Mbed TLS est le seul encodeur de cet article qui fait ses lookups en temps constant, parce que ceux qui écrivent de la crypto embarquée ne font pas confiance à un indexage de tableau variable en temps, même dans un codec qui n'est pas un chiffreur. La paranoïa est transférable.
- La règle d'encodage canonique - les bits de padding inutilisés doivent être zéro - paraît triviale jusqu'à ce que vous appreniez que la violenter veut dire que deux chaînes différentes peuvent se décoder en les mêmes octets, ce qui casse tous les contrôles « cette chaîne est-elle l'encodage de ce fichier ? » qui existent. Vos encodeurs sont tous conformes ; c'est pourquoi base64 est une représentation stable au hachage et peut remplacer un nom de fichier dans un store de contenu.
- L'
EVP_EncodeBlockd'OpenSSL est l'une des rares fonctions C dont la valeur de retour, sa propre sortie et son terminateur NUL sont tous d'accord : il écritncaractères, un NUL, et renvoien. Dans un langage célèbre pour l'off-by-one, c'est un moment de paix. - APR-Util est le seul encodeur ici qui se demande ce que veut dire EBCDIC, parce qu'Apache tourne encore sur des machines où les lettres sont dans un ordre différent de celui de l'ASCII. Sur ces machines, « encoder » une chaîne inclut d'abord de réordonner discrètement son alphabet.
- L'entrée vide s'encode en chaîne vide dans chaque bibliothèque, sans pads et sans retours à la ligne. L'élément identité du format, présent et correct dans les quatre, ce qui en fait le test unitaire le moins cher que vous écriverez jamais.
Passer du côté du décodeur
Donc voilà pour le côté emballage : le calcul, les quatre encodeurs, les dialectes, et les endroits où vont les octets. C'est la moitié calme du boulot, parce que l'encodage n'a pas d'entrée invalide et pas de décodeur pour être en désaccord avec vous. L'autre direction - rencontrer le base64 du monde extérieur et récupérer les octets - est là où la douleur se concentre : queues bourrées de zéros, troncature silencieuse, alphabets stricts contre conciliants, et une ligne de commande qui mange les retours à la ligne de fin. Le décodage Base64 en C est couvert en profondeur dans l'article relié, lié depuis cette page, et c'est le compagnon naturel de celui-ci : l'encodeur écrit la boîte, le décodeur l'ouvre, et entre les deux vous avez tous les boulots base64 qu'un programme C pourra jamais croiser.
Dernière mise à jour : 2026-09-08
Article associé : Décodage Base64 en C : un guide complet