Werk je met Base64-indeling? Dan is deze site perfect voor jou! Gebruik onze handige online tool om je gegevens te coderen of te decoderen.

Base64-codering in C: een complete gids

Je hebt bytes. Misschien zijn het een JPEG van schijf gelezen, misschien een token, misschien een wachtwoord dat een client over enkele seconden gaat verzenden, misschien de rauwe bytes van een bestand dat ergens in de pipeline in een JSON-veld verwacht wordt. En ergens verderop is er een kanaal dat alleen tekst spreekt: een JSON-string, een URL, een e-mailbody, een configuratiebestand, een databasekolom die doet alsof hij tekst is. Daar komt Base64-encoding om de hoek kijken: het schrijft elke drie bytes rauwe data om naar vier tekens uit een alfabet van 64 letters, zodat het resultaat platte ASCII is die elke tekstpipeline op aarde overleeft. De startpagina van deze site legt het formaat volledig uit; dit artikel gaat over het goed doen van het werk in C, waar - zoals gebruikelijk - de taal het niet voor je doet.

Het ene getal om in je hoofd te houden: encoding laat je data groeien. Drie bytes worden vier tekens, dus elke payload verlaat je programma ongeveer een derde groter, plus een beetje meer zodra er regeleindes bijkomen. Dat is de belasting, en die zit er vast - maar in C heeft die belasting een rijtje in de rekening, want jij reserveert de outputbuffer zelf en die moet exact groot genoeg zijn. Doet de berekening één keer goed en elke encoder in dit artikel wordt voorspelbaar: geen overloops, geen onderlopen, geen vragen waar de volgende byte zal belanden. Dan is er nog de gereedschapskist waaruit je kiest - OpenSSL, Mbed TLS, APR-Util, GLib - en de keuze doet er toe, want elke wikkelt anders af, sluit anders af, en faalt anders.

Vier encoders, vier persoonlijkheden

Alle vier de libraries encoden het standaardalfabet correct en identiek - dezelfde bytes erin, dezelfde tekens eruit, altijd. De verschillen zitten in de verpakking, en de verpakking is waar interoperabiliteitsbugs zich verstoppen. Hier is het landschap:

Library Header Outputstijl Foutmodus
OpenSSL (libcrypto) <openssl/evp.h> Geen regeleindes; schrijft een NUL-terminator Praktisch geen (alleen allocatie)
Mbed TLS <mbedtls/base64.h> Geen regeleindes; NUL-afgesloten Buffer-te-klein-code met de nodige grootte
APR-Util <apr-1.0/apr_base64.h> Geen regeleindes; voegt een NUL toe Geen - vertrouw op je buffergrootte
GLib <glib.h> Geen regeleindes; NUL-afgesloten, op de heap gereserveerd Geeft NULL terug (alleen allocatie)

Let op wat er ontbreekt in de tabel: geen van hen wikkelt regels af, niet in de standaard. Dat is met opzet - RFC 4648 zegt dat implementaties geen regeleindes mogen toevoegen tenzij de omringende specificatie er expliciet om vraagt - en het is verlichting, want een verdwaald regeleinde in een JSON-string of een URL is een fout, geen feature. Wrappen is er voor e-mail en PEM, en wanneer je het nodig hebt, krijg je het van het streamende pad van OpenSSL, of wikkel je het zelf af in vijf regels (de e-mailsectie toont beide). Voor het kiezen van een library: gebruik OpenSSL als je hem al linkt, Mbed TLS voor embedded-builds waar over elke kilobyte wordt getwist, APR-Util binnen het Apache-ecosysteem, en GLib wanneer de rest van je programma al GLib is. Installatie: libssl-dev (Debian/Ubuntu) of openssl-devel (Fedora/RHEL) of brew install openssl (macOS); libmbedtls-dev voor Mbed TLS; libaprutil1-dev plus libapr1-dev voor APR-Util; glib2.0-dev voor GLib.

Do de berekening vóór je reserveert

Eerst de rekenkunde, want C redt je niet als je buffer te klein is. Elke drie bytes invoer produceren exact vier tekens uitvoer. Als de invoerlengte geen veelvoud van drie is, produceert de laatste groep nog steeds vier tekens, en de ongebruikte slots worden gemarkeerd met =-pads: één invoerbyte wordt vier tekens met twee pads, twee invoerbytes worden vier tekens met één pad. De exacte geëncodeerde lengte voor n bytes is dus:

size_t encoded_chars(size_t n) {
  return ((n + 2) / 3) * 4;
}

Voor 1000 bytes is dat 1336 tekens. Voor 1 byte is het 4. Voor 0 is het 0. Daaruit volgen twee aanpassingen. Eerst voegen zowel OpenSSL als Mbed TLS een NUL-terminator toe na de data (en Mbed TLS reserveert ruimte ervoor als je om de grootte vraagt), dus je buffer wil één extra byte: encoded_chars(n) + 1. Tweede, als je omwikkelde output wilt, voeg je één regeinde toe per regel: de streamende encoder van OpenSSL geeft een regel van 64 tekens voor elke 48 invoerbytes, dus de omwikkelde lengte is encoded_chars(n) + (n + 47) / 48. Controleer met 1000 bytes: 1336 tekens plus 21 regeleindes is 1357, en dat is precies wat de encoder produceert. Schrijf de formule één keer op als functie en gebruik het overal. Dat is het verschil tussen "het past" en een heap-corruptie om 3 uur 's nachts.

size_t b64_buffer_size(size_t in_len) {
  return ((in_len + 2) / 3) * 4 + 1; /* tekens + NUL */
}
size_t b64_buffer_size_wrapped(size_t in_len) {
  return ((in_len + 2) / 3) * 4 + (in_len + 47) / 48 + 1;
}

OpenSSL: één blok of een lopende kraan

De one-shot-functie van OpenSSL is het werkpaard, en het is de meest toegankelijke van de groep:

#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;
}

Het schrijft de geëncodeerde tekens naar out, voegt er een NUL achter, en geeft de lengte exclusief de NUL terug - dus printen met %s is veilig, en de lengte is beschikbaar als je hem nodig hebt. De outputbuffer moet encoded_chars(n) + 1 bytes kunnen bevatten. Er is geen foutpad om af te handelen: encoding kan niet mislukken, want elke byte is legale input, en de functie heeft geen concept van inputvalidatie waarop het zou kunnen struikelen. De enige manier waarop het fout kan gaan is als je een te kleine buffer geeft, en de wiskunde-sectie is het tegengif.

Het streamende paar is voor wanneer de data groot is of in stukken binnenkomt. EVP_EncodeUpdate verwerkt input in blokken van 48 bytes en schrijft per volledig blok 64 tekens plus een regeinde (65 bytes), en houdt de restant vast in de context tot er meer data of de laatste aanroep komt:

#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 tekens + 21 regeleinden + ruimte */
  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;
}

De output van dat programma is 1357 bytes met 21 regeleindes - de formule uit de wiskunde-sectie, in het echt. Twee praktische notities. De versienoot: sinds OpenSSL 1.1.0 (2016) is het contexttype ondoorzichtig, dus reserveer met EVP_ENCODE_CTX_new() en vrijgeef met EVP_ENCODE_CTX_free(). Het oudere stackpatroon EVP_ENCODE_CTX ctx; dat je in tutorials voor 1.0.2 en ouder tegenkomt, compileert niet tegen moderne headers, OpenSSL 3.x inclusief. En de ontwerpnotitie: omdat alleen volledige blokken van 48 bytes uit EVP_EncodeUpdate komen, voedt de netteste chunk-pipeline het met veelvouden van 48 - dan is elke regel die de functie schrijft een afgeronde regel, en beslist EVP_EncodeFinal alleen hoe de staart wordt omwikkeld. Als je input in willekeurige groottes binnenkomt (een netwerklezing), regelt de context de uitlijning ook voor je. De gewoonte van veelvouden van 48 is gewoon wat de output voorspelbaar maakt.

Mbed TLS: eerst vragen, dan encoderen, string eruit

De encoding van Mbed TLS heeft de schoonste overeenkomst van de groep, opgebouwd rond een grootte-query die je kunt aanroepen met een NULL-bestemming:

#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;
}

Lees de details zorgvuldig, want het is een masterclass in een vriendelijke API. De grootte-query meldt needed als de geëncodeerde tekens plus één voor de NUL - voor "Mane" is dat 8 plus 1, dus 9 - en het geeft zichzelf te kennen met de "buffer te klein"-code (MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL, dat is -0x002A), want een NULL-bestemming is per definitie te klein. De echte aanroep schrijft de tekens en de NUL, en *olen komt terug als 8 - de lengte exclusief de terminator - dus de buffer is al een afprintbare C-string. Als je een buffer geeft die één byte te kort is, krijg je dezelfde "te klein"-code terug, met de vereiste grootte in *olen, zodat het falen je exact vertelt hoeveel tekort je had. Nog een notitie: de library doet zijn tabel-opzoeken via constante-tijd-helpers, een kleine aanraking van zorgvuldigheid die je in de meeste encoders niet ziet.

APR-Util en GLib: de andere twee

De encoder van APR-Util is een gewoon paar functies met int-lengtes:

#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;
}

Hier tellen zowel apr_base64_encode_len() als de retourwaarde de NUL mee, dus n is één meer dan het tekental - een boekhoudingsverschil met OpenSSL en Mbed TLS dat in meer dan één codebase tot off-by-one-bugs heeft geleid. Geldt dezelfde 32-bits lengtelimiet: voor waarden in de buurt van of boven 2 GB is dit niet het juiste gereedschap. Er is ook apr_base64_encode_binary(), die op EBCDIC-machines de EBCDIC-naar-ASCII-conversie van de input slaat - op de mainframes waar die conversie anders zou plaatsvinden, en overal anders is het een no-op verschil. Dat paar, de binaire variant en de bijbehorende decode-functies zijn in feite het hele base64-oppervlak dat apr-util blootlegt: er is geen pool-allocatie-variant en geen streaming-variant, dus het malloc-patroon hierboven is het enige patroon.

De encoder van GLib is de heap-allocatie-stijl - je krijgt een NUL-afgesloten string, en daarbij een plicht:

#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;
}

Geen wikkelen, NUL-afgesloten, vrijgeven met g_free - de G_GNUC_MALLOC-annotatie op de prototype is wat statische analyzers dat vertelt. Wanneer je wél regeleindes wilt, is het incrementele paar het gereedschap: g_base64_encode_step() neemt een state-integer en een break_lines-vlag, zegt je hoeveel outputbytes het schreef, en g_base64_encode_close() maakt de laatste deelgroep af. Dat is dezelfde staat-machinenvorm als het streamende paar van OpenSSL, alleen dan met de parameterstijl van GLib.

Handgemaakte URL-safe Base64

Alle vier de libraries hierboven spreken het standaardalfabet: A-Z, a-z, 0-9, plus en slash. Maar het web spreekt steeds vaker het tweede dialect uit RFC 4648 sectie 5, genaamd base64url: dezelfde encoding met + omgeruild voor - en / omgeruild voor _, en de trailing-=-opvulling weggelaten wanneer de lengte bekend is. JSON Web Tokens, OAuth state-parameters en ontelbare API-ID's gebruiken het, want + en / zijn beide gevaarlijk in URLs, terwijl - en _ ongereserveerde tekens zijn die ongehinderd doorkomen. Aangezien geen C-library dit dialect native uitstoot, maak je het zelf - en het is twee kleine veranderingen, want je hebt al een standaardalfabet-encoder:

#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'; /* opvulling opzettelijk weggelaten */
}

Gebruik is twee stappen - encodeer standaard, vertaal daarna:

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 */

Twee waarschuwingen. De loop stopt bij de eerste =. Dat is wat de opvulling weghaalt - "fix" dat niet, de opvulling weghalen is het doel (de ontvanger die het nodig heeft kan het terugtoevoegen vanuit de lengte). En bepaal de grootte van out op de volledige geëncodeerde lengte, niet minder: de vertaling is teken-voor-teken tot aan de pads, dus de capaciteit die je al hebt gereserveerd voor de standaardvorm is precies goed. Een eerlijke notitie over interoperabiliteit: als je data toevallig geen bytes bevat die op + of / mappen, zijn de standaardvorm en de URL-safe vorm identiek, en zal niemand ooit klagen over de verwarring - de bug komt pas naar boven wanneer de data er eindelijk één in heeft. Behandel het dialect als een eigenschap van het kanaal (URL, token), niet van de data.

Tekst en karaktersets: UTF-8 is gewoon bytes

Een vraag die C-beginners verrast: wat gebeurt er met accenttekens, emoji's, CJK-tekens? Het antwoord is het meest bevrijdende feit in dit artikel - er hoeft niets te gebeuren. Base64 werkt op bytes, en C is een taal van bytes. Als je tekst UTF-8 is (en dat is het in 2026 bijna zeker), is de UTF-8-encoding van "café" vijf bytes - 63 61 66 c3 a9 - en Base64 encodeert die vijf bytes exact zoals elke andere vijf bytes, wat Y2Fmw6k= oplevert. Geen charset-parameter, geen BOM, geen conversiestap, geen library-aanroep. De codec weet niet, en het boeit hem ook niet, wat de bytes betekenen. Dat is het hele ontwerp.

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *utf8 = "caf\303\251"; /* café in 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;
}

Twee valkuilen zitten aan de randen van deze sectie. De eerste is wchar_t: als je data als brede karakters binnenkomt, moet je die eerst omzetten naar een bytereeks (op Linux, UTF-8, via wcstombs() of je locale-mechanisme) vóórdat je encodeert - de Base64 van een wchar_t-array is de encoding van een interne representatie, niet van de tekst, en die verschilt tussen platforms. De tweede is bron-encoding: de string-literal in je C-bestand is geëncodeerd in de encoding van het bronbestand (in elk modern project UTF-8), dus "café" direct schrijven werkt, zolang het bestand écht UTF-8 is en je compiler dat weet (in moderne toolchains het geval, standaard). Encodeer de bytes die je bedoelt te sturen, en laat de ontvanger bepalen wat de bytes betekenen.

Afbeeldingen: van buffer naar string

De meest voorkomende "echte" encoding-taak in web C: een binair bestand - een JPEG, een PNG, een icoon - moet door een tekstkanaal, dus het wordt een Base64-string. Het recept is: lees het bestand in een buffer, bepaal de output met de formule, encodeer en ga door. De bestandslezende helft verdient zorg, want daar breken C-programma's écht:

#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;
}

Notities: rb voor binaire lees - ononderhandelbaar op elk platform, want tekstmodus kan bytes vertalen en got veranderen; het fseek/ftell-paar voor de grootte (voor pipes en sockets waar je niet kunt zoeken, lees je in plaats daarvan in een groeiende buffer); en got in plaats van size voor de encoding, want een korte lees is een echte mogelijkheid. Een afbeelding van 1 MB wordt zo'n 1,33 MB tekst - dat is de belasting, vooraf gefactureerd, en het is de reden waarom een base64-in-JSON afbeeldingspayload je zou moeten laten stoppen en vragen of een échte bestandupload goedkoper was geweest.

Bestanden en de .b64-gewoonte

De andere kant van de afbeeldingstaak: je moet de Base64-vorm van een bestand naar schijf schrijven - een .b64-sidecar, een back-up van een binair bestand in een tekstveilige opslag, een bijlage voor een mailer. Zelfde wiskunde, andere schrijver. De gewoonte die de moeite waard is om aan te nemen, is om de tekstoutput te schrijven met expliciete regeleindes van een lengte die de ontvangerzijde verwacht - 76 voor e-mail, 64 voor PEM-stijl-ontvangers, of helemaal niet als de ontvanger je eigen code is:

#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;
}

De loop schrijft regels van 76 tekens en een kortere regel als laatste; decoders die whitespace overslaan (alle serieuzen doen dat) laten zich er helemaal niet om drukken qua regellengte, en daarom is het de ontvanger, niet jouw smaak, die je moet raadplegen. Houd het outputbestand aan de schrijfkant in tekstmodus (w) als je platform-specifieke regeleindes wilt, of wb als de ontvanger karakten strikt telt - en als het telt, wil het exact wat je beloofd hebt: 76 tekens plus één regeinde, niets anders. Die belofte, niet de bytes, is wat een .b64-bestand tot een formaat maakt.

Data URIs: embedding in het echt

Data URIs (RFC 2397) zijn de andere kant van de geliefde binnenkomer uit het decode-artikel: in plaats van data:image/png;base64,... te ontvangen, bouw je er zelf een. De vorm is data:, media type, ;base64, komma, payload - en het bouwen ervan in C is één snprintf na de encoding:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  /* de 8-byte PNG-signatuur */
  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;
}

Drie ontwerpnotities. De media type die je in de URI zet is een claim waarvoor jij verantwoordelijk bent - check eerst de magic bytes van het echte bestand, anders zal een data:image/png met een JPEG erin elke ontvanger op een andere manier verwarren. De ;base64-vlag is verplicht wanneer de payload Base64 is; laat hem weg en de payload moet in plaats daarvan percent-geëncodeerde tekst zijn, wat een heel ander formaat is. En de eigen richtlijn van de RFC is dat data URIs zijn voor korte waarden: een logo van 5 MB inline in een HTML-pagina embedden werkt, maar het is een design smell dat een echte asset-URL zou oplossen. Dezelfde constructie duikt steeds op in JSON-API's waar een client een avatar wil in dezelfde request als de formulierdata - encodeer, voorvoeg, verstuur.

HTTP en JSON: payloads die overleven

De grootste moderne reden om in C te encoderen is JSON. Een JSON-string is een reeks karakters met escape-regels, en rauwe bytes passen daar niet in: een NUL midden in een string-literal is een C-probleem, een letterlijke regeleinde in een JSON-string is ongeldige JSON, en willekeurige bytes hebben een gedefinieerd escape-verhaal nodig. Base64 loopt het hele probleem uit de weg door alleen tekens te produceren die JSON nooit hoeft te escapen - de 64 alfabettekens plus, in het standaarddialect, =, en geen van die is een aanhalingsteken of backslash. Binair gaat erin als string en komt aan de andere kant eruit exact zoals het erin ging:

#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;
}

Dit print {"avatar": "aGVsbG8="} - een compleet, geldig JSON-object, geen escape-mechaniek nodig, en de precisie van %.*s houdt de lengte exact, ook als je ooit wisselt naar een encoder die niet NUL-afsluit. De eerlijke prijs is de grootte: elke byte die je als JSON-string verstuurt kost je 4/3 van een byte in de lucht, plus de veldnaam en de aanhalingstekens, dus een binair bestand van 10 KB wordt een string van 13,3 KB in de JSON. Voor af en toe een kleine blob (iconen, miniaturen, handtekeningen, tokens) is dat een eerlijke prijs; voor een upload van 500 MB is het een architectuur waar je op terugkijkt, en een échte bestandupload is het gereedschap voor die taak. Nog een regel die de moeite waard is: de + en / van het standaardalfabet zijn veilig binnen een JSON-string, maar als dezelfde string later in een URL-query reist, is dat niet zo - dat is het werk van de URL-safe-sectie.

JWTs: drie delen, één alfabet

De vlaggenschip-gebruiker van base64url in C is de JSON Web Token. Een compacte JWT volgens RFC 7519 is drie base64url-geëncodeerde delen die met punten aan elkaar geplakt zijn - header, payload, signature - en het bouwen ervan is een aangenaam oefenonderwerp, want elk onderdeel is een functie die je al hebt: encodeer standaard, vertaal naar URL-safe, signeer, herhaal. Hier is een HS256-token gebouwd met de HMAC van 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;
}

Twee dingen die de structuur leert. Eerst is de tekeningsinput de twee URL-safe delen die met een punt aan elkaar geplakt zijn - exact de bytes die de ontvanger zal zien - dus de vertaling naar base64url moet vóór het tekenen gebeuren, niet erna; signeer de standaardalfabet-vorm en de verificatie van de ontvanger faalt, en dat is een bug die compileert, draait, en eruitziet als een sleutel-mismatch. Tweede, de header en de payload zijn platte JSON in een Base64-omhulsel: iedereen kan ze lezen, en dat is het ontwerp. Een token is een ondertekend briefje, niet een verzegelde envelop - dus stop er niets in dat je niet wilt dat een afsnemmende gebruiker leest, en stop nooit, onder geen beding, een wachtwoord in een JWT-payload "omdat het geëncodeerd is". Het Base64-deel van deze taak is klein en saai, en dat is het grootste compliment dat je een JWT-implementatie kunt geven.

HTTP Basic Auth: het token bouwen

De oudste authenticatieheader is ook de simpelste Base64-taak: username:password, geëncodeerd in het standaardalfabet, na het woord Basic. Het bouwen ervan in C is twee regels, en het enige subtiele punt is dat het wachtwoord een dubbelepunt mag bevatten (en de splitsing aan de ontvangerzijde moet op de eerste plaatsvinden):

#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;
}

Dit print Authorization: Basic YWxpY2U6czNjcjN0. De waarschuwing van de RFC geldt ook aan de verzenderzijde: dit is encoding, geen bescherming. Over een platte HTTP-verbinding is de aanmelding één base64 -d verwijderd van iedereen op de lijn, dus Basic auth is een uitsluitend-HTTPS-gewoonte. (De moderne alternatieven - bearer tokens, mTLS - hergebruiken allemaal dezelfde machine: stel een string samen, encodeer hem, en stop hem in een header. Base64 is al sinds het protocol headers heeft de manier van HTTP om gestructureerde data door tekstheaders te smokkelen.)

E-mail en PEM: waar het wikkelen thuis hoort

E-mail is de reden waarom regelomwikkeling überhaupt bestaat. SMTP beperkt de regellengte, dus MIME bracht geëncodeerde regels naar maximum 76 tekens (PEM, de voorouder, naar 64), en elk mailsysteem heeft die limiet dertig jaar gerespecteerd. Als je C-programma Base64 produceert voor een e-mailbody of een bijlage, is het wikkelen geen optionele cosmetica - een niet-omwikkelde regel van 200 KB wordt door onderdelen van de mailinfrastructuur afgewezen of verprutsd. De streamende encoder van OpenSSL geeft je omwikkelde output gratis (op zijn erfde lengte van 64 tekens), en wanneer je exact 76 nodig hebt, is het omwikkelen van een one-shot-resultaat een loop van vijf regels:

#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;
}

Let op de \r\n: e-mail wil CRLF-regeleindes, en als de geëncodeerde tekst een van meerdere MIME-delen is, volgt alles rondom het base64-blok dezelfde regels - regellengtes, CRLF-eindes, geen uitzonderingen. PEM-bestanden (het formaat van de meeste sleutels en certificaten) gebruiken hetzelfde idee met regels van 64 tekens tussen -----BEGIN- en -----END-markeringen, en de tools van OpenSSL verwachten dat harnas te zien als je een sleutel opnieuw opslaat - dus als je programma PEM raakt, wikkel dan af op 64 en behoud de labels. Overal anders - JSON, URLs, API's, databases - geldt de regel van de RFC en wikkel je helemaal niet.

Waarden smokkelen door configs en kolommen heen

Het rustige gebruiksscenario: waarden die een tekstformaat zouden breken, worden als Base64 verpakt zodat ze dat niet doen. Een database-DSN met puntkommas, een wachtwoord met aanhalingstekens, een token met een regeleinde - de operator encodeert ze één keer en het configuratiebestand ziet de problemenkaracters nooit:

#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;
}

Het programma print de exacte regel die je in een .env-bestand plakt, en de C-code die hem later leest is een getenv plus een decode. Drie eerlijke voorbehouden, allemaal over wat dit níét is. Het is geen versleuteling: iedereen die het configbestand kan lezen, kan de waarde in één aanroep decoderen, dus verpak nooit een secret als Base64 en noem het beschermd. Het is geen escaping: als het formaat structuur intact wil houden, is een echte encoding (percent-encoding voor URLs, JSON-escaping voor JSON) het juiste gereedschap, en Base64 is voor de waarden die die formaten niet kunnen uitdrukken - de binaire. En het kost grootte: een waarde die als Base64 in een database-TEXT-kolom staat, neemt zo'n 33 procent meer ruimte in dan het origineel, prima voor tokens en een echt getal voor bestandskolommen (waarvoor BLOB-kolommen bestaan).

Encoderen vanuit de shell

Vóórdat je naar een cc-aanroep grijpt, onthoud dat beide standaardtools encoderen, en snel. coreutils is het algemene instrument: base64 encodeert standaard met een wrap van 76 tekens, -w verandert de kolom, en -w 0 schakelt het wikkelen helemaal uit:

base64 photo.png > photo.b64
base64 -w 0 photo.png > photo-oneline.b64
cat note.txt | base64 -w 0

De tool van OpenSSL is dezelfde taak met TLS-afkomstige verpakking: openssl base64 (de vriendelijke alias van openssl enc -base64) wikkelt af op 64 tekens, en -A schakelt het om naar één regel:

openssl base64 < photo.png > photo.b64
openssl base64 -A < photo.png > photo-oneline.b64

Waar is het verschil in wikkelen belangrijk? Omdat de standaardoutput van de twee tools niet uitwisselbaar is als een downstreaamparser tekens telt - 76 per regel versus 64 per regel is een zichtbaar verschil in het bestand, en een parser die whitespace strippt doet er niet om, maar een die de regellengte valideert wél. Wanneer je C-programma de producent is en de shell de consument (of andersom), spreek eerst af hoe je wikkelt. Nog een dialect-notitie voor BSD-achtige systemen: de decodevlag daar was historisch -D, en oudere macOS-releases herinneren zich dat nog; de encodeerkant is overal base64, en dat is de enige richting waar deze sectie over gaat.

De grote dingen streamen

Een bestand van meerdere gigabytes in één malloc encoderen is een geheugenprobleem dat je niet nodig had. Het streamende pad bestaat precies hiervoor, en de blokdiscipline van OpenSSL maakt de code bijna triviaal: voed EVP_EncodeUpdate met zoveel als het bestand je geeft, laat het de restant van elk deels blok van 48 bytes vasthouden in de context, en schrijf de 65 outputbytes van elk blok rechtstreeks naar het bestemmingsbestand. Piekeheugen is je twee buffers - enkele tientallen kilobytes - hoe groot het bestand ook is:

#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];            /* een veelvoud van 48: nette regels */
  unsigned char outbuf[1024 * 65 + 65];  /* 65 outputbytes per 48-byte blok, plus 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;
}

Twee ontwerpdetails doen werk in die loop. De inputbuffer is een veelvoud van 48 bytes, dus elke aanroep reikt de encoder volledige blokken, en elke regel die hij schrijft is een afgeronde regel van 64 tekens; de laatste aanroep wikkelt dan de echte staart af. Als je input in willekeurige groottes komt (een socket, een trage schijf), absorbeert de context de uitlijningsfout nog steeds correct - de keuze van veelvoud van 48 gaat om output-voorspelbaarheid, niet om correctheid. Het tweede detail is de grootte van de outputbuffer: 65 bytes per 48 invoerbytes plus de marge van het laatste blok (66), wat de omwikkelde formule uit de wiskunde-sectie is, per chunk toegepast. Voortgangsrapportage is één regel - tel de bytes die naar out zijn geschreven tegen het totaal dat je uit de bestandsgrootte berekend hebt - en omdat encoding de data laat groeien, zal het outputbestand zo'n 33 procent groter landen dan de input: factureer de schijf vooraf.

De scherpe randen van encoding

De vallen, verzameld, allemaal C-vormig:

  • Off-by-one, in beide richtingen. OpenSSL geeft de lengte zonder de NUL terug; de grootte-query van Mbed TLS bevat ruimte voor de NUL; APR telt de NUL mee in zijn lengtes. Drie libraries, drie boekhoudconventies. Schrijf de buffergrootte-functie één keer (de wiskunde-sectie) en stop met het in je hoofd rekenen op het aanroep-punt.
  • De NUL is een byte waarvoor je moet betalen. Elke encoder in dit artikel wil één extra byte in de output voor de terminator, en dat geldt ook voor elke buffer-append die je handmatig schrijft. Een buffer die exact encoded_chars(n) groot is, is één byte te kort zodra iemand printf("%s") wil laten werken.
  • Encoding kan niet falen, dus je mag het niet laten overlopen. Er is geen foutcode die een te kleine buffer vangt - de encoder schrijft blij door het einde heen. Het falenmodel van Base64-encoding in C is geheel van jou: bepaal de grootte goed, anders corrupteert het geheugen zonder diagnostics.
  • Dubbele encoding is de klassieke stille bug. Een waarde die al Base64 is, die nog eens door de encoder gaat, produceert een perfect geldige Base64-string die decodeert naar een Base64-string in plaats van de data. Het symptoom - "het decodeert, maar naar het verkeerde ding" - kost een middag om te vinden. Als een waarde "voor-geëncodeerd" binnenkomt, verifieer dan dat de lengte een veelvoud van vier is en alleen alfabettekens bevat voordat je aannemt dat het rauwe data is; als het geëncodeerd is, sla de encoding over.
  • Het plusteken in URLs. Standaardalfabet-output die in een query string gaat, komt binnen met het + omgezet in een spatie op het moment dat je server het formulier parseert - het + is een spatie in percent/form-encoding. Tokens en ID's die in URLs reizen willen het URL-safe dialect, punt.
  • Tekstmodus aan de verkeerde kant van de pijp. Een binair bestand in tekstmodus lezen kan bytes vertalen (op sommige platforms) en je lengte veranderen; omwikkelde Base64 schrijven met de verkeerde regeinde-conventie breekt ontvangers die karakten tellen. rb voor binaire input, expliciet \r\n of \n waar een specificatie dat eist, en laat de C-runtime nooit in stilte over je regeleindes beslissen.
  • int-overflow in de grootteberekening. ((n + 2) / 3) * 4 in int-rekenwerk loopt over bij input boven zo'n 1,5 GB, en produceert een kleine positieve "nodige grootte" en een heap-smash. Doe de berekening in size_t (of uint64_t), en dat is ook de reden waarom de int-gebaseerde API van APR een plafond van 2 GB heeft dat je niet weg kunt engineren.
  • Wikkelen waar de ontvanger het niet verwacht. RFC 4648 zegt: geen regeleindes tenzij de omringende specificatie vraagt. Een regeleinde in een JSON-stringwaarde is ongeldig; in een URL is het een andere request. Wikkel voor mail, wikkel voor PEM, en nergens anders.

De korte checklist

Bereken de buffergrootte met de formule, niet met een gok, en houd één grootte-functie aan voor de hele codebase. Houd (pointer, lengte) bij elkaar, ook wanneer de buffer NUL-afgesloten is, want de lengte is de overeenkomst en de NUL is een comfort. Kies het dialect naar het kanaal: standaard voor JSON en bodies, URL-safe voor URLs en tokens, omwikkeld voor mail en pantsering, niet-omwikkeld overal anders. Verifieer de magic bytes voordat je een MIME-type claimt in een data URI. Gebruik Base64 nooit als versleuteling, als vervanging voor percent-encoding, of als plek om een secret te verstoppen - het is een doos, geen slot. En wanneer de data groot is, stream dan: de blok-gebaseerde encoders zijn daar precies voor ontworpen, en constant geheugen is het hele punt.

Geschiedenis: hoe het verpakken gestandaardiseerd raakte

De geschiedenis van de encoder is het verhaal van regellengtes. De eerste Base64 was een C-programma uit het begin van de jaren negentig. Privacy-Enhanced Mail (RFC 1421, 1993) moest binair door 7-bit e-mail kunnen vervoeren, en de auteurs kozen zes bits per teken in regels van 64 tekens - de 64 is een relict van de regellengte-tolerantie van SMTP, en de C-code deed het verpakken tabel-opzoeking na tabel-opzoeking. Toen MIME hetzelfde alfabet voor het web standaardiseerde (RFC 1521 in 1993, RFC 2045 in 1996), versoepelde het de regel naar 76 tekens, en de wereld draagde twee gewoonten - 64 en 76 - die allebei claimden "de" Base64-regellengte te zijn. De encoders volgden mee: het streamende pad van OpenSSL hield 64 vast (zijn PEM-erfgoed), de tool van coreutils koos 76 (zijn MIME-erfgoed), en de twee tools op dezelfde machine komen nog steeds niet overeen over waar de regeleindes moeten komen. De standaard nam eindelijk een standpunt in, in 2006: RFC 4648 zei dat implementaties helemaal geen regeleindes mogen toevoegen, tenzij de verwijzende specificatie hen dat expliciet voorschrijft, en daarom staat elke library in dit artikel standaard op niet-omwikkelde output, en daarom is wikkelen nu een opt-in feature voor e-mail en harnas. Het alfabet zelf, de opvulregels en de canonicaliteitsregel "pad-bits moeten nul zijn" dateren uit die eerdere PEM- en MIME-RFC's, en 4648 herhaalt ze als de canonieke regels voor het gezin. En sectie 11 van de RFC wijst naar een referentieimplementatie - een ISO C99-programma, extern gehost omdat de code zelf "niet kon worden opgenomen in deze RFC om procedurele redenen" - nog een herinnering dat in dit formaat C geen burger van de tweede rang is. De C-standaardlibrary is er voor zijn deel nooit achteraan gekomen: C89 bevroor in 1990, voordat iets van dit alles bestond, en C23 in 2024 wordt nog steeds geleverd zonder Base64-functie. Dus de libraries waarmee je linkt zijn de standaard, en de keuze ertussen is een kleine maar echte ontwerpbeslissing - en dat is waar dit artikel over ging.

Vreemde kleine feitjes

Een paar feitjes die gewoon leuk zijn, allemaal over de verpakkingkant in C:

  • "De 64" is het grondtal: elk outputteken is zes bits, en 2 tot de 6 is 64. Het formaat noemt zijn alfabet zoals C zijn gehele getallen noemt - naar wat het getal écht is.
  • Het blok van 48 bytes van de streamende encoder van OpenSSL is geen willekeurige boekhouding: 48 invoerbytes zijn precies 16 groepen van 3, en 64 outputtekens zijn precies 16 groepen van 4. Beide getallen zijn veelvouden van 16, en dat is het soort rondheid waar hardware en cache-regels blij van worden - of ten minste de mensen die de code lezen.
  • Één invoerbyte encodeert naar vier tekens, waarvan twee = zijn. De kleinste mogelijke niet-lege payload is 50 procent opvulling - de meest verspillende encoding in het formaat, en degene die elke test suite gebruikt omdat het zo makkelijk verkeerd te schrijven is.
  • Mbed TLS is de enige encoder in dit artikel die zijn opzoeken in constante tijd doet, want de mensen die embedded crypto schrijven vertrouwen tabel-indexering met variabele tijd niet, zelfs niet in een codec die geen cipher is. De paranoia overdraagt zich.
  • De canonieke-encoding-regel - ongebruikte pad-bits moeten nul zijn - klinkt triviaal totdat je leert dat het overtreden ervan betekent dat twee verschillende strings naar dezelfde bytes kunnen decoderen, en dat breekt elke "is deze string de encoding van dat bestand?"-check die bestaat. Je encoders voldoen allemaal; daarom is base64 een hash-stabile representatie en kan het staan voor een bestandsnaam in een content store.
  • OpenSSL's EVP_EncodeBlock is een van de zeldzame C-functies waarvan de retourwaarde, de eigen output en de NUL-terminator het allemaal eens zijn: het schrijft n tekens, een NUL, en geeft n terug. In een taal die beroemd is om off-by-one, is dat een moment van vrede.
  • APR-Util is de enige encoder hier die zich afvraagt wat EBCDIC betekent, want Apache draait nog steeds op machines waar de letters in een andere volgorde staan dan in ASCII. Op die machines betekent "encoderen" van een string eerst in stilte het alfabet hersorteren.
  • De lege input encodeert in elke library naar de lege string, zonder pads en zonder regeleindes. Het identiteitselement van het formaat, aanwezig en correct in alle vier, en dat maakt het de goedkoopste unit test die je ooit zult schrijven.

Overstappen naar de decoderkant

Dus dat was de verpakkingkant: de wiskunde, de vier encoders, de dialecten, en de plekken waar de bytes naartoe gaan. Het is de kalme helft van het werk, want encoding heeft geen ongeldige input en geen decoder die het met je oneens is. De andere richting - de Base64 van de buitenwereld tegemoet treden en de bytes terugkrijgen - is waar het leed zich concentreert: nul-opgevulde staarten, stille afkapping, strikte versus soepele alfabetten, en een command line die trailing newlines op eet. Base64-decoderen in C komt uitgebreid aan bod in het gerelateerde artikel, gelinkt vanaf deze pagina, en het is de natuurlijke metgezel van dit artikel: de encoder schrijft het doosje, de decoder opent het, en tussen de twee heb je elke Base64-taak die een C-programma ooit zal ontmoeten.

Laatst bijgewerkt: 2026-10-06

Gerelateerd artikel: Base64-decodering in C: een complete gids