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-decodering in C: een complete gids

Hij zit in een API-antwoord, een configuratiebestand, een e-mailbijlage of midden in een URL: een lange reeks letters en cijfers, af en toe een + of /, en misschien nog een of twee = aan het einde. Je herkent hem direct, en nu heb je de oorspronkelijke bytes terug nodig - in C. Dat is het hele werk van Base64-decoderen: vier alfabettekens gaan erin, drie rauwe bytes komen eruit, keer op keer, totdat de =-tekens je vertellen waar de echte data ophield. De startpagina van deze site behandelt het formaat stap voor stap, dus dit artikel besteedt zijn energie aan waar het echte werk is: buffers, libraries en de vallen die daartussenin leven.

Twee dingen om te weten vóór de eerste malloc. Eerst is decoderen de verkleinende richting: de output is drie kwart van de grootte van de input, dus een decoder heeft nooit meer geheugen nodig dan de payload die hij al vasthoudt. Tweede - en dat is de kop - C levert geen Base64-decoder mee. De standaardlibrary van de taal bevroor lang voordat Base64 bestond, en geen enkele standaard daarna heeft die leegte opgevuld. Dus elk C-programma dat Base64 decodeert steunt op een library, en de vier die in de praktijk meetellen zijn OpenSSL, Mbed TLS, APR-Util en GLib. Elke heeft een andere persoonlijkheid: wat het vergeeft, hoe het fouten meldt, en wat het in stilte met je output doet. Zodra je de persoonlijkheid van je decoder kent, stopt Base64-decoderen in C met het een bron van mysterieuze bugs zijn, en wordt het een routine die je in je slaap kunt schrijven.

De gereedschapskist: vier manieren om je bytes terug te krijgen

Hier is het landschap in één oogopslag. Alle vier dekken het standaardalfabet; de verschillen zitten in de randen, en de randen zijn waar bugs vandaan komen.

Library Header Foutmodel Quirk in de output om te onthouden
OpenSSL (libcrypto) <openssl/evp.h> Geeft -1 terug bij slechte input De one-shot decoder vult de staart op met nullen
Mbed TLS <mbedtls/base64.h> Retourcodes (-0x002C, -0x002A) Striktste invoerregels van de vier
APR-Util <apr-1.0/apr_base64.h> Geen: stopt bij het eerste raar karakter API met int-lengtes, dus 2 GB is het plafond
GLib <glib.h> Geeft NULL terug bij een harde mislukking Negeert in stilte verdwaalde rommel

Installatie is één pakketnaam per distro. Voor OpenSSL: libssl-dev op Debian en Ubuntu, openssl-devel op Fedora en RHEL, openssl op Arch en brew install openssl op macOS. Voor Mbed TLS: libmbedtls-dev (of mbedtls). Voor APR-Util: libaprutil1-dev plus libapr1-dev. Voor GLib: glib2.0-dev. Vervolgens link je respectievelijk met -lcrypto, -lmbedcrypto, -laprutil-1 of -lglib-2.0. Welke kies je? Als je OpenSSL al linkt voor TLS of hashing (de meeste servers doen dat), gebruik dan OpenSSL. Voor embedded en geheugengevoelige builds is Mbed TLS de kleine, strikte burger. Als je binnen het Apache-ecosysteem zit, is APR-Util er al. Als je codebase op GNOME of GTK is gebouwd, houdt GLib alles in één runtime.

OpenSSL: de decoder die de gaten vult met nullen

OpenSSL heeft Base64 in twee varianten. De one-shot-functie is de ster van de meeste 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;
}

Geef het een buffer met Base64-tekens en een lengte, en het schrijft de gedecodeerde bytes naar out en geeft het aantal terug. Het haalt whitespace aan het begin weg, haalt whitespace en regeleinden aan het einde weg, en weigert input die na het opruimen geen veelvoud van 4 tekens is of een karakter buiten het alfabet bevat. Tot hier is het een volkomen verstandige overeenkomst. Behalve één detail dat in stilte meer dan één database-import heeft gecorrumpeerd: de retourwaarde is niet de echte datalengte.

Voer dat programma uit en je krijgt 4 bytes... wacht, nee. TWFuZQ== is twee groepen van vier tekens, dus de functie geeft 6 terug, en de buffer bevat 4d 61 6e 65 00 00: het woord "Mane" plus twee nul-bytes. De one-shot-decoder van OpenSSL werkt in vaste kwanta - elke vier invoertekens produceren altijd exact drie outputbytes - en als de laatste groep maar één echt byte bevatte, worden de andere twee slots opgevuld met nullen. De manual noemt dit in één kalm zinnetje ("de output wordt indien nodig opgevuld met 0-bits"), en dat ene zinnetje is de belangrijkste zin in de hele man page van deze functie.

De echte lengte kun je terugrekenen uit de opvulling, en dat is een berekening van twee regels:

size_t real_length(const char *b64) {
  size_t len = strlen(b64);
  while (len > 0 && b64[len - 1] == '=') len--;
  return len * 3 / 4;
}

Tel de alfabettekens, haal de trailing pads weg, vermenigvuldig met drie, deel door vier. Voor TQ== (de letter M, geëncodeerd) geeft dat (2 * 3) / 4 = 1 echt byte - terwijl EVP_DecodeBlock drie zal melden. Houd het (pointer, lengte)-paar altijd bij elkaar, en gebruik nooit strlen op gedecodeerde data, want de bytes die je terugkrijgt kunnen een JPEG zijn, en de eerste daarvan kan een NUL zijn.

De streamende decoder: een decoder die weet wanneer hij moet stoppen

Voor alles anders biedt OpenSSL het streamende paar EVP_DecodeUpdate plus EVP_DecodeFinal. Het contextobject is wat de status tussen de aanroepen verplaatst: het houdt één tot drie tekens van een onafgemaakte groep vast, zodat je de payload in chunks kunt voeden. Het gedrag dat ertelt is dit: whitespace (spaties, tabs, retourtekens, regeleindes) wordt overal in de stream overgeslagen, elk ander niet-alfabet-karakter of een = midden in de data geeft onmiddellijk -1 terug, en een retourwaarde van 0 van een update betekent "de opvulling is gezien, er wordt niets meer verwacht". EVP_DecodeFinal weigert daarna met -1 als er nog een deelsgroep in de wachtrij staat, want een lengte die geen veelvoud van vier is (na whitespace) is geen geldige payload.

Eén versienoot vóór de code, want oude tutorials laten je struikelen: in OpenSSL 3.x is het contexttype EVP_ENCODE_CTX ondoorzichtig, dus het stackpatroon EVP_ENCODE_CTX ctx; dat in veel internetcode te vinden is, compileert niet meer. Reserveer en vrijgeef expliciet:

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

Bepaal de outputbuffer op in_len * 3 / 4 + 3 en de aanroep is veilig voor elke input. Kijk hoe het een MIME-omwikkelde payload afhandelt waar de regeleinde midden in een groep valt:

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

De regeleinde verdwijnt, de vier bytes komen eruit, en niemand hoefde de input vooraf te reinigen. Er is een bonusverschil ten opzichte van de one-shot-functie: het streamende pad telt bytes eerlijk. Voer het TQ== in en het geeft exact één byte (4d) terug, zonder nul-opvulling, want het begrijpt dat twee pads betekenen dat twee van de drie outputslots nooit gevuld werden. Als je payload ooit een betrouwbare lengte van OpenSSL nodig heeft, is dit het pad om te gebruiken.

Mbed TLS: de strikte

Mbed TLS (de cryptolibrary die zijn leven begon als PolarSSL en nu in de embedded-stacks van ARM wordt geleverd) geeft je twee functies met een zeer nette overeenkomst:

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);

Decodeer zoals een zorgvuldig persoon zou doen. Roep het aan met dst ingesteld op NULL (of dlen op nul) en het zegt je de vereiste grootte in *olen zonder iets te doen; roep het echt aan en je krijgt 0 bij succes, MBEDTLS_ERR_BASE64_INVALID_CHARACTER (dat is -0x002C) als er iets in de input niet klopt, of MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL (dat is -0x002A) als de bestemming te klein is. De gedecodeerde lengte belandt in *olen, en in tegenstelling tot de one-shot-functie van OpenSSL is dat altijd het eerlijke getal: het decoderen van TQ== geeft je één byte, 4d, niets meer.

De invoerregels zijn de strikste van de vier libraries, en de moeite waard om te onthouden, want ze definiëren wat "geldig" betekent voor Mbed TLS:

  • CRLF- en LF-regeleindes mogen tussen groepen voorkomen - e-mailpayloads werken zo als ze zijn.
  • Spaties zijn toegestaan vlak vóór een regeinde en aan het allereinde van de buffer, maar een spatie na een regeinde of midden in een groep is een fout.
  • Maximaal twee =-tekens, en alleen aan het einde; elke data na een pad is een fout.
  • Elke byte boven 127 (accenten, UTF-8-fragmenten, binaire rommel) is een fout.

Die laatste regel is de een die bijt: als een payload komt van een bron die de tekenset-encoding heeft verprutsd, dan wijst Mbed TLS hem af waar een luiere decoder hem met een schouderophaling had gedecodeerd. Voor alles dat onbetrouwbare input raakt, is strikt een feature. Een complete decode ziet er zo uit:

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

(Dat de grootte-query de "te klein"-code teruggeeft, is bewust zo: zo rapporteert de functie wat het had gaan schrijven. Beide retourcodes hierboven komen uit <mbedtls/base64.h>, dezelfde header waarin de functie is verklaard.)

APR-Util en GLib: nog twee stoelen

APR-Util - de utilitelibrary van de Apache Portable Runtime, het fundament waarop de Apache HTTP Server is gebouwd - draagt Base64 al zo lang de server Basic-auth-headers hoeft te decoderen. De API is een klein gezin functies op basis van 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);

Twee dingen om te weten vóór je ernaar grijpt. Eerst zijn de lengtes int: 32-bit, dus het praktische plafond is 2 GB per aanroep, prima voor headers en configuratiewaarden, maar niet prima voor het decoderen van een bestand van 4 GB. Tweede - en dit is de grote - de decode-functie heeft helemaal geen foutretourwaarde. Het gedrag is alleen zichtbaar in de implementatie, niet in de header: de decoder beschouwt elk ongeldig karakter, inclusief whitespace en NUL, als een eindpunt. Het decodeert tot het eerste ding dat het niet herkent, geeft terug hoe ver het kwam, en zegt niets. Een afgekapte payload, een plaksel met een commentaar aan het einde, een gecorrumeerde byte in het midden - het produceert allemaal een in stilte te korte output. Als je de decoder van APR gebruikt, moet je de teruggegeven lengte vergelijken met wat de payload belooft; de functie doet het niet voor je. Er is geen pool-allocatie-wrapper - jij levert de bestemmingsbuffer, dus in pool-gedreven code reserveer je plain_dst zelf uit de pool. Er is ook een EBCDIC-hoek die je nergens anders in dit artikel vindt: op EBCDIC-machines converteren de functies de input naar ASCII vóór de encoding en terug na het decoderen, zodat dezelfde code draait op de mainframes die httpd nog steeds draaien.

GLib, de runtime achter GTK en de meeste GNOME-toepassingen, heeft de tegenovergestelde persoonlijkheid. Zijn decoder accepteert een string en geeft altijd een vers gereserveerde buffer terug (NULL alleen als je een NULL-pointer overhandigt), decodeert wat het kan en slaat de rest in stilte over:

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

De val zit in het woord "altijd". De decoder van GLib hoort bij de nagevole school: tekens buiten het alfabet worden overgeslagen, niet fataal. Voer het TWFuZ@== in en het geeft de drie bytes van "Man" terug zonder een vinger te bewegen. Er is ook een handige in-place-variant, g_base64_decode_inplace(), die over de inputbuffer zelf decodeert (veilig, want de output is korter dan de input) en dezelfde pointer teruggeeft, zodat het resultaat begint aan het begin van de buffer - een slimme truc voor geheugengevoelige code, en het eet CRLF-omwikkelde input graag op. De les voor C-ontwikkelaars: als je data onbetrouwbaar is, zal GLib je niet redden van een gecorrumeerde payload. De _step-varianten (g_base64_decode_step met een state-integer) zijn beschikbaar als je incrementeel decoderen nodig hebt, en het bijbehorende g_base64_encode_step/g_base64_encode_close-paar zit aan de encoderkant.

URL-safe Base64: het andere alfabet

Iergens tussen het standaardalfabet en je URL's raakte iemand gewond. Standaard Base64 gebruikt + en / als de twee hoogste symbolen, en beide zijn in URLs een ramp: een + in een query string wordt routinematig als een spatie geïnterpreet op het moment dat je server hem te zien krijgt, en / is een pad-scheidingsteken. RFC 4648, sectie 5, definieert de oplossing, genaamd base64url: dezelfde encoding met + vervangen door -, / vervangen door _, en de trailing-=-opvulling weggelaten als de lengte op een andere manier bekend is. JSON Web Tokens, OAuth state-parameters en ontzettend veel API-sessie-ID's leven in dit dialect.

Geen van de vier C-libraries decodeert base64url native, dus de conversie is een kleine helper die je één keer schrijft en hergebruikt: zet de twee speciale tekens terug, voeg ontbrekende opvulling weer toe, en geef het resultaat daarna door aan je standaarddecoder. Eerst de lengtecheck, want een lengte van één meer dan een veelvoud van vier is in geen enkel Base64-dialect mogelijk:

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

Twee valkuilen bewaken deze weg. De eerste is richting: als je een URL-safe payload in de standaarddecoder stopt zonder de tekenwissel, weigeren OpenSSL en Mbed TLS hem (die tekens zitten niet in hun alfabet), terwijl GLib de - en _ in stilte overlaat en een string teruggeeft die korter is dan hij zou moeten zijn - zonder fout. Ga altijd via de helper. De tweede is de eigen waarschuwing van de RFC, die serieus genomen hoort te worden: base64url "mag niet als hetzelfde beschouwd worden als de base64-encoding". Als een payload toevallig geen - of _-tekens bevat, zijn de twee dialecten byte-identiek voor die data, en is een verwarring onzichtbaar - precies waarom de verwarring overleefd totdat hij een payload raakt die er wél één in heeft.

Bestanden: het origineel herstellen

De meest voorkomende bestandstaak is het omgekeerde van wat een exportroutine deed: er komt een .b64-tekstbestand binnen, en je hebt het originele bestand terug nodig. Lees de hele tekst, decodeer hem, en laat dan de bytes zich zelf voorstellen voordat je enige label vertrouwt. C heeft geen finfo, dus de praktische test is een magic-number-check over de eerste paar bytes:

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

Notities over de randen: open het bestand in binaire modus (rb/wb) ook voor de tekst-helft, want tekstmodus vertaalt regeleindes op sommige platforms en corrompeert je tekentelling; en gebruik nooit printf("%s") op de gedecodeerde buffer om "te zien wat het is". De magic-byte-check is de eerlijke manier om die vraag te stellen, en als je het herstelde bestand later naar een browser stuurt, moet de Content-Type uit dezelfde check komen, niet uit de bestandsnaam.

Data URIs: de afbeelding in de URL

Een geliefde binnenkomer uit de wereld van het web: iemand plakt een afbeelding in een formulier, en de front end reikt je server een complete data URI aan zoals data:image/png;base64,iVBORw0KGgo.... RFC 2397 definieert de vorm: data:, een optionele media type, een optionele ;base64-vlag, een komma, en dan de payload. Als de vlag aanwezig is, is de payload Base64; als hij ontbreekt, is de payload percent-geëncodeerde platte tekst - zeldzamer, maar legaal. Als de media type wordt weggelaten, is de standaard text/plain;charset=US-ASCII. Het parsen ervan in C is een kwestie van de komma vinden en te kijken wat er vlak ervoor zit:

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

En de aanroeper leest als een zin:

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);
  /* decodeer nu de payload met je favoriete bibliotheek */
}

Drie valkuilen wonen in dit formaat. De ontbrekende ;base64-vlag is de eerste: een legale data URI zonder vlag heeft een percent-geëncodeerde payload, en dat door een Base64-decoder halen produceert rommel - check de vlag, en kies daarna je decoder. De aangegeven media type is de tweede: het is een hint van de afzender, geen feit; de magic-byte-check uit de bestanden-sectie is jouw feit. De derde is grootte: het eigen advies van de RFC is dat data URIs zijn voor korte waarden, dus een afbeelding van meerdere megabytes die in een URL rijdt is een design smell in je architectuur, geen patroon om te vieren.

JWTs: de niet-geheime delen lezen

De beroemdste Base64-payload op het web is de JSON Web Token, en de minst angstaanjagende zodra je de vorm kent. Volgens RFC 7519 is een compacte JWT drie base64url-delen die met punten aan elkaar geplakt zijn: een header, een payload en een signature - elk geëncodeerd zonder opvulling, zonder regeleindes. De eerste twee delen zijn platte JSON, daarom kan iedereen ze lezen, en daarom moet iedereen doorlezen vóórdat hij een token aanraakt.

De eerste twee delen lezen is een paar regels met de base64url-helper van hierboven, en het is de snelste manier om een token te ontrafelen:

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

Geprint is de header {"alg":"HS256","typ":"JWT"} en de payload {"sub":"1234567890","name":"John Doe"}. Nu het deel dat ertelt: het derde deel is een signature, en de twee delen die je zojuist gedecodeerd zijn noch geheim noch geauthenticeerd. Iedereen met een packet capture kan ze lezen, en iedereen met een teksteditor kan ze herschrijven. De payload van een JWT in C vertrouwen vóórdat je de signature verifieert is de klassieke authenticatiefout, en Base64 maakt het makkelijk om het niet te merken - het token lijkt onbreekbaar maar is een ansichtkaart. Om een HS256-token te verifiëren bereken je de HMAC-SHA256 over header.part opnieuw met je secret, met HMAC() uit <openssl/hmac.h>, en vergelijk je in constante tijd met CRYPTO_memcmp(); als de digests het niet eens zijn, wordt het token afgewezen, hoe groot de claims ook zijn. Er is geen de facto standaard JWT-library in C, dus voor productie bouw je die kleine verificatiestap zelf, of neem je een van de community-libraries over - maar de Base64-kant van het werk is de split-en-decodeer-dans hierboven, en dat moet je allemaal begrijpen.

Basic Auth: de header die nooit privacy heeft geleerd

De oudste authenticatieheader op het web rijdt nog steeds op Base64: Authorization: Basic gevolgd door de standaardalfabet-encoding van username:password (RFC 7617, waarop RFC 9110 doelt voor het Basic-schema). De RFC is expliciet dat dit encoding is, geen bescherming - iedereen met een packet capture kan beide helften in één commando decoderen - dus het werk aan de decodeerkant in C is de header parsen, strikt decoderen, splitsen op de eerste dubbele punt (wachtwoorden mogen legaal dubbele punten bevatten), en vergelijken met een timing-safe functie:

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

De lengtecheck doet echt werk: hij voorkomt dat een payload die decodeert naar "alice:secret" met rommel achteraan, of "alice:secre" afgekapt, komt te matchen. En CRYPTO_memcmp (of memcmp alleen als je de timing-implicaties begrijpt) is wat een aanvaller eruit houdt om op basis van timing zijn weg door je gebruikerslijst te timen. Dien deze header aan via HTTPS, of helemaal niet - op een platte verbinding is de Base64-laag raamdecoratie.

E-mail en PEM: de oorspronkelijke thuisbasis

Base64 is geboren voor een heel specifiek probleem: e-mailtransport droeg alleen 7-bit ASCII, en mensen wilden binaire bestanden erdoorheen sturen. MIME (RFC 2045) maakte Base64 een van de standaard transfer encodings en voegde twee huishoudelijke regels toe: geëncodeerde regels mogen niet langer zijn dan 76 tekens, en decoderende software moet tekens buiten het alfabet negeren - regeleindes inbegrepen. Die tweede regel is de reden waarom de streamende decoders hierboven een omwikkelde bijlage doorbijten zonder enige voorverwerking, en waarom de gewoonte van 76 tekens nog steeds is ingebakend in elke mail-library op aarde. De voorouder was PEM (Privacy Enhanced Mail, RFC 1421), die in plaats daarvan regels van 64 tekens gebruikte - de 64/76-splitsing die je in tools ziet is die geschiedenis, beide limieten in de uiteindelijke analyse opgelegd door SMTP.

PEM-pantsering - het formaat waarin sleutels en certificaten reizen - is gewoon gelabeld Base64: een -----BEGIN ... ------regel, de body in regels van 64 tekens, en een passende END-regel. De pantsering strippen in C is een regel-scan, en dan doet de decoder de rest:

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

De gedecodeerde bytes zijn DER, een compacte binaire serialisatie, en dat is wat de certificaat- en sleutfuncties van OpenSSL uiteindelijk verbruiken. Twee notities: verzamel de body zonder de regeleindes (zoals de loop doet) zodat je lengte een veelvoud van vier is, en als een bestand meerdere blokken heeft, match de END-label met het BEGIN-label dat je opende - een simpele vlag werkt als je alleen het eerste blok wilt, zoals hier.

Secrets, configs en databasekolommen

Base64 is een tekstcontainer, en daarom duikt het steeds op in plekken waar je het niet verwacht. In configuratiebestanden en omgevingsvariabelen is het de truc om waarden te smokkelen die anders het formaat zouden breken: een database-DSN met puntkommas, een wachtwoord met aanhalingstekens, een waarde met een regeleinde. In databases kan een binaire blob in een tekstkolom leven als Base64 en overleven aan elke tool die van tekst uitgaat - tegen de prijs, wel, van zo'n derde extra formaat, dus dimensioneer je kolommen daarnaar (of vraag je af waarom de waarde niet gewoon in een BLOB-kolom staat).

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

De waarschuwing geldt dubbel. Eerst is dit formatveiligheid, geen geheimhouding: het moment dat een developer het configbestand kan lezen, kan die waarde in één aanroep decoderen, en het beveiligingshoofdstuk van de RFC noemt echte incidenten waarin mensen een protocolwisselverloop aan support meldden en "per ongeluk het wachtwoord onthulden" omdat Base64 visueel vermomt, niet computationeel beschermt. Bewaar nooit een secret als Base64 en noem het versleuteld. Tweede, valideer bij het opstarten: een halve geplakte omgevingswaarde is een -1 van de strikte aanroep, en een eén-regel-check maakt een cryptisch falen drie uur later tot een uitvoerbaar bericht bij het opstarten.

Decoderen vanuit de shell

Niet alles decoderen gebeurt binnen je programma. CLI-scripts, cron-jobs en one-liners decoderen constant Base64, en C-ontwikkelaars moeten de twee tools kennen die al op elke Linux-machine staan. De coreutils-tool is de algemene: base64 -d decodeert, -i zorgt dat het rommeltekens negeert in plaats van te falen, en -w stelt de wrap-kolom in (wat alleen de encoding affecteert, niet de decoding):

base64 -d < blob.b64 > blob.bin
base64 -d -i < messy.b64 > blob.bin

OpenSSL levert een eigen versie, bereikbaar als openssl base64 (een vriendelijker alias van openssl enc -base64):

openssl base64 -d < blob.b64 > blob.bin
openssl base64 -d -A < blob.b64 > blob.bin

De -A-vlag betekent "één regel": encodeer zonder de wrap van 64 tekens, en verwacht dat de input ook één regel is. En hier is een CLI-val die je een avond kost als je hem niet leest: de base64-decode van OpenSSL is regel-gericht, en een payload die helemaal zonder regeleinde binnenkomt, decodeert naar niets, in stilte:

printf 'TQ=='  | openssl base64 -d | wc -c   # 0
printf 'TQ==\n' | openssl base64 -d | wc -c  # 1

De coreutils-decoder heeft die verwachting niet, en dat is één reden waarom het de veiligere standaard is voor samenplakwerk. Nog een dialect-notitie: op BSD-afgeleide systemen (vooral oudere macOS) was de decodevlag historisch -D; moderne releases volgen de GNU-conventie van -d, dus check de man page op de machine waar je echt zit.

Grote payloads, klein geheugen

Decoderen is de richting die je helpt: de output is drie kwart van de grootte van de input, dus geheugenpressure van Base64 is zeldzaam. Maar als een bestand van honderden megabytes aan .b64 op schijf belandt, is het streamende pad van eerder jouw tool, en het is eenvoudiger dan het lijkt. Lees het geëncodeerde bestand in chunks, voed elke chunk door naar EVP_DecodeUpdate, en schrijf de gedecodeerde bytes weg zodra ze binnenkomen. Het contextobject houdt de één-tot-drie tekens van elke onafgemaakte groep vast tussen de aanroepen, dus chunk-grenzen mogen overal vallen - je hoeft ze niet uit te lijnen:

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

Piekgeheugen is twee buffers van een orde van enkele tientallen kilobytes, ongeacht de bestandsgrootte, en een gecorrumpeerd bestand faalt snel - EVP_DecodeUpdate geeft -1 terug bij de chunk waar de schade zit, dus kun je een offset rapporteren in plaats van je schouders te ophalen. Eén library-voorbehoud voor dit pad: de decoder van APR-Util werkt op NUL-afgesloten strings met boekhouding van int-grootte (de retourwaarde is een int en de input is gekapt op net onder 3 GB door een interne constante), dus zit het buiten beschouwing voor bestanden van meerdere gigabytes. Als je voortgangsrapportage nodig hebt, tel de bytes die je hebt geschreven - dat is jouw positie in de output, en de inputpositie is ruwweg vier derde daarvan.

De vallen, allemaal C-specifiek

Vergezeld in één plek, de vallen die specifiek zijn voor dit doen in C:

  • De nul-opgevulde one-shot. EVP_DecodeBlock geeft de quantumlengte terug, niet de datalengte. TQ== meldt drie bytes maar vervoert er één. Bereken de echte lengte altijd opnieuw uit de trailing pads, of gebruik het streamende paar.
  • Gedecodeerde bytes zijn geen string. Het resultaat kan NUL-bytes bevatten en hoeft geen UTF-8 te zijn. Geen strlen, geen printf("%s"), geen doorgeven aan functies die van tekst uitgaan. Draag (pointer, lengte) overal bij je.
  • Buffergrootte is jouw klus. C laat je outputbuffer niet groeien, en de decoders ook niet - de update van OpenSSL schrijft wat het decodeert in de ruimte die je hebt gegeven. Bepaal de grootte op in_len * 3 / 4 + 3 (plus wrap-overhead als de input omwikkeld is en je decodeert met een helper die niet stript) en houd een cap-check in elke wrapper.
  • Opzoeken met signed char. Als je ooit zelf een decoder schrijft, is de klassieke bug om de inputbyte als index te gebruiken in een tabel van 256 entries met een gewone char op een platform waar char signed is: byte 0xFF wordt -1 en je indexeert achteruit door het geheugen. Indexeer altijd met unsigned char- of unsigned-waarden.
  • De stilzwijgenden zijn de gevaarlijkste. APR-Util stopt bij het eerste ongeldige karakter en zegt niets; GLib slaat rommel over en zegt niets. OpenSSL en Mbed TLS falen luid. Als je input onbetrouwbaar is, is de stilte van de library een bug in je programma, niet in de library.
  • De command line eet regeleindes op. openssl base64 -d decodeert nul bytes als de input geen regeleinde heeft. Shell-pipelines die trailing newlines strippen (tr -d '\n', xargs, editor-bewerkingen zonder laatste regeleinde) produceren lege output zonder fout.
  • int versus size_t. De one-shot-API van OpenSSL neemt een lengte van int, APR-Util gebruikt int doorlopend, en de Mbed TLS- en GLib-API's gebruiken size_t. Rekenwerk met gemengde lengtes ertussen is waar signed/unsigned-waarschuwingen echte bugs verbergen - en waar het plafond van 2 GB van APR zit.
  • Whitespace is niet uniform. OpenSSL slaat alle whitespace over, overal; Mbed TLS staat CRLF/LF toe tussen groepen en spaties vlak vóór een regeinde, maar niet daarna of halverwege een regel; de CLI-tools verschillen. Een payload die geldig is voor de ene decoder kan ongeldig zijn voor de andere, en "het werkte op mijn machine" betekent meestal "mijn decoder was lui".

Goede gewoonten, verzameld

Valideer voordat je vertrouwt: een vormcheck (alfabettekens, maximaal twee trailing pads) vangt voor de hand liggende rommel af vóór elke decode, maar alleen een echte decode begrijpt de semantiek van Base64, dus krijgt de strikte decoder het laatste woord. Gebruik het streamende paar van OpenSSL wanneer je eerlijke lengtes of chunked input nodig hebt, en de one-shot wanneer de payload klein is en je de lengte onmiddellijk corrigeert. Houd (pointer, lengte)-paren bij elkaar en laat een gedecodeerde buffer nooit aan een string-functie komen. Vergelijk authenticatiemateriaal met CRYPTO_memcmp. Check de magic bytes voordat je een bestandsnaam of een aangegeven MIME-type gelooft. En behandel Base64 als wat het is - een verpakkingsformaat, een klein doosje voor bytes - niet als een slot: niets aan deze 64 letters maakt je data privé.

Korte geschiedenis van Base64 in C

Het verhaal begint met e-mail. In 1990 en 1991 schetste een groep cryptografen Privacy Enhanced Mail, een systeem voor ondertekende en versleutelde e-mail, en ze hadden een manier nodig om binair door een 7-bit-netwerk te vervoeren. Hun antwoord, gestandaardiseerd als RFC 1421 in 1993, encodeerde data zes bits per teken - "base 64" - in regels van 64 tekens, en de implementatie was, uiteraard, C. Ongeveer tegelijk arriveerde het web met zijn eigen MIME, RFC 1521 (1993) en daarna RFC 2045 (1996), die hetzelfde alfabet behield, de regellengte versoepelde naar 76, en Base64 de bijlagevorm van het jonge internet maakte.

De standaardlibrary van C miste de hele boot. De C89-standaard werd gepubliceerd in 1990, drie jaar vóór MIME, en het comité van de taal heeft sindsdien nooit een Base64-functie toegevoegd - niet in C99, niet in C11, niet in C23 (de revisie van 2024). Zo groeide het ecosysteem rond de libraries: OpenSSL draagt de EVP encode/decode-routines in libcrypto al zo lang als iemand OpenSSL linkt voor TLS, Mbed TLS (in 2015 hernoemd van PolarSSL) hield een klein strikt paar voor embedded-systemen, APR-Util werd geleverd met Apache toen de server zijn eigen auth-headers moest decoderen, en GLib voegde zijn trio toe voor het desktop. De standaards joegen de implementaties achterna: RFC 3548 in 2003 ruimde de oude definities op, en RFC 4648 in 2006 (Base-N Encodings) formaliseerde de alfabetten, de URL-safe variant, en de beveiligingsregels waar dit artikel op steunt. Passend genoeg wijst sectie 11 van die RFC naar een ISO C99-referentieimplementatie - de eigen voorbeelddecoder van de standaard is geschreven in C, en dat zegt alles over waar dit formaat thuis hoort.

Leuke weetjes, C-editie

Een paar C-geurende feitjes die gewoon leuk om te weten zijn:

  • De naam is wiskunde, geen marketing: elk outputteken draagt exact zes bits, en 2 tot de 6 is 64. "Base64" is het grondtal, hardop uitgesproken.
  • Het alfabet is 65 tekens, niet 64: de 64 symbolen plus =, wat RFC 4648 "het extra 65e teken" noemt, gebruikt voor een speciale verwerkingsfunctie. Het pad is een arbeider, geen letter.
  • OpenSSL wikkelt geëncodeerde output af op 64 tekens (de PEM-gewoonte) terwijl coreutils wikkelt op 76 (de MIME-gewoonte). Het verschil van 12 tekens is twee decennia e-mailgeschiedenis die je ziet in de output van twee commando's op dezelfde machine.
  • De auteur van het GNU coreutils base64-commando is Simon Josefsson - dezelfde persoon die RFC 4648 schreef. De standaard en een van de meest gebruikte implementaties delen een auteur, zo komen de twee het over elke edge case eens.
  • Mbed TLS doet zijn tabel-opzoeken via constante-tijd-helpers (mbedtls_ct_base64_*), zodat de decode-snelheid niet lekt welke tekens het zag. Een detail dat je nooit zult merken en dat je blij bent dat bestaat.
  • TQ== is de kleinste niet-triviale payload: één echt byte, twee pads. Het is de perfecte testvector - de one-shot-decoder van OpenSSL geeft drie bytes voor terug, de streamende decoder geeft één, Mbed TLS geeft één, en GLib geeft één. Vier libraries, twee antwoorden, en het verschil is de nul-opvulling.
  • De base64-functies van APR zijn de enigen in dit artikel die zich om EBCDIC bekommeren, omdat httpd nog draait op machines waar letters niet ASCII zijn. De C-standaardlibrary is nooit een mainframe tegengekomen; APR wel.
  • De lege payload is de universele identiteit: elke library encodeert en decodeert nul-lengte-input naar nul-lengte-output, zonder fout. Als je decoder stropt op een lege string, heb je een bug, geen formaat.

Overstappen naar de encodeerkant

Dat was de decodeerkant, en dat is waar het grootste deel van het leed zit, want decoderen is waar je de data van anderen ontmoet: hun opvulkeuzes, hun regeleindes, hun gecorrumeerde bytes, hun tokens. De tegengestelde richting - bytes omzetten naar een Base64-string - is een kalmer dier met zijn eigen cast van vallen: exacte bufferberekening, de vraag van het regelomwikkeling, en de factuur over de grootte die bij elke afzender terechtkomt. Base64-encoding in C komt uitgebreid aan bod in het gerelateerde artikel, gelinkt vanaf deze pagina, en het vormt een paar met dit artikel op dezelfde manier waarop een decoder een paar vormt met een encoder: lees beide, en je zult door geen van beide richtingen ooit meer verbaasd worden.

Laatst bijgewerkt: 2026-10-06

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