需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

C 语言中的 Base64 解码:完整指南

它可能藏在 API 响应、配置文件、邮件附件里,或者 URL 的中间:一长串字母和数字,偶尔冒出个 + 或 /,末尾也许还缀着一两个 =。你一眼就能认出它,而现在你需要把原始的字节拿回来 - 用 C。这就是 Base64 解码的全部工作:四个字母表字符进去,三个原始字节出来,如此反复,直到 = 标记告诉你真正的数据在哪里结束。本站首页已经把格式一步步讲清楚了,所以这篇文章把精力花在真正干活的地方:缓冲区、库,以及藏在两者之间的陷阱。

在第一次 malloc 之前,先记住两件事。第一,解码是缩小的方向:输出是输入的四分之三大,所以解码器永远不需要比它已经握在手里的载荷更多的内存。第二 - 也是本文的头条 - C 语言并不自带 Base64 解码器。这门语言的标准库在 Base64 诞生之前很久就定型了,此后的任何标准也没有补上这个缺口。所以每个需要解码 Base64 的 C 程序都要依靠某个库,而实践中真正重要的有四个:OpenSSL、Mbed TLS、APR-Util 和 GLib。它们各有各的脾气:各自原谅什么、怎么报告错误,又会在背后悄悄对你的输出做什么。一旦摸清了解码器的脾气,在 C 里解码 Base64 就不再是神秘 bug 的来源,而变成一段你闭着眼都能写出来的常规操作。

工具箱:把字节拿回来的四种方式

先鸟瞰一下全局。四家都支持标准字母表;差别在边缘,而 bug 恰恰就出生在边缘。

库 头文件 错误模型 要记住的输出怪癖
OpenSSL(libcrypto) <openssl/evp.h> 坏输入时返回 -1 一次性解码器会给尾部补零
Mbed TLS <mbedtls/base64.h> 返回码(-0x002C、-0x002A) 四家中输入规则最严格
APR-Util <apr-1.0/apr_base64.h> 没有:遇到第一个怪字符就停 基于 int 长度的 API,所以上限是 2 GB
GLib <glib.h> 只在彻底失败时返回 NULL 悄悄忽略零散的垃圾

安装就是每个发行版一个包名。OpenSSL:Debian 和 Ubuntu 上是 libssl-dev,Fedora 和 RHEL 上是 openssl-devel,Arch 上是 openssl,macOS 上是 brew install openssl。Mbed TLS:libmbedtls-dev(或 mbedtls)。APR-Util:libaprutil1-dev 加上 libapr1-dev。GLib:glib2.0-dev。然后分别用 -lcrypto、-lmbedcrypto、-laprutil-1 或 -lglib-2.0 链接。该选哪一个?如果你已经为了 TLS 或哈希链接了 OpenSSL(大多数服务器都是),就用 OpenSSL。嵌入式和资源受限的构建,Mbed TLS 是那个小而严格的公民。如果你身处 Apache 生态,APR-Util 本来就在那里。如果你的代码库基于 GNOME 或 GTK,GLib 能把一切都收进同一个运行时。

OpenSSL:那个用零填满空位的解码器

OpenSSL 提供两种口味的 Base64。一次性函数是大多数代码里的主角:

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

喂给它一段 Base64 字符的缓冲区和长度,它把解码后的字节写进 out,并返回写入了多少。它会修剪前导空白,修剪尾随空白和换行,并且拒绝修剪之后不是四个字符整数倍的输入,或者拒绝包含字母表之外字符的输入。到这里,还是一份完全合理的契约。除了一个已经悄悄弄坏过不止一次数据库导入的细节:返回值不是真实的数据长度。

运行那个程序,你会得到 4 bytes……不,等等。TWFuZQ== 是两个四字符组,所以函数返回 6,缓冲区里装着 4d 61 6e 65 00 00:单词 "Mane" 加上两个零字节。OpenSSL 的一次性解码器按固定单元工作 - 每四个输入字符永远恰好产出三个输出字节 - 当最后一个组只携带一个真实字节时,另外两个槽位就填上零。手册只用一句平静的话提到了这件事("如有必要,输出会用 0 位填充"),而这短短一句,才是这个函数整个 man 页面里最重要的一句话。

真实长度要从填充里找回,它只是一个两行的计算:

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

数一下字母表字符,丢掉尾部的填充,乘以三,再除以四。对 TQ==(编码后的字母 M)来说,结果就是 (2 * 3) / 4 = 1 个真实字节 - 而 EVP_DecodeBlock 会报告三个。永远把(指针、长度)这对值放在一起,并且永远不要对解码后的数据用 strlen,因为你拿回来的字节可能是一张 JPEG,而其中第一个字节可能就是 NUL。

流式解码器:一个知道何时该停的解码器

其余的一切,OpenSSL 都交给了流式组合 EVP_DecodeUpdate 加上 EVP_DecodeFinal。上下文对象负责在各次调用之间搬运状态:它保留一三个未完成组的字符,这样你就可以分块喂入载荷。真正要紧的行为是这样的:空白(空格、制表符、回车、换行)在流中任何位置都会被跳过,任何其他字母表之外的字符,或者数据中间的 =,都会立即返回 -1,而 update 返回 0 意味着"已经看到填充,不再期待任何数据"。之后 EVP_DecodeFinal 会拒绝并返回 -1,如果此时还有一个未完成组挂在那里 - 因为长度(去除空白后)不是四的倍数,就不是合法载荷。

看代码之前先说一版版本问题,因为老教程会绊倒你:在 OpenSSL 3.x 里,上下文类型 EVP_ENCODE_CTX 是不透明的,所以大量网络代码里的栈式写法 EVP_ENCODE_CTX ctx; 已经编译不过了。请显式分配和释放:

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

把输出缓冲区按 in_len * 3 / 4 + 3 来定大小,这次调用对任何输入都是安全的。看它处理一个 MIME 折行的载荷,换行恰好落在一个组的中间:

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

那个换行消失了,四个字节出来了,谁也不需要预先清理输入。它和一次性函数之间还有一个额外的差异:流式路径诚实地数字节。喂给它 TQ==,它恰好返回一个字节(4d),没有任何零填充,因为它懂得两个填充意味着三个输出槽位中有两个从未被填入。如果你的载荷需要一个来自 OpenSSL 的可信长度,就用这条路径。

Mbed TLS:严格派

Mbed TLS(这家密码学库最初叫 PolarSSL,如今内置于 ARM 的嵌入式栈中)给你两个函数,契约非常干净:

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

像一个谨慎的人会做的那样去解码。把 dst 设为 NULL(或把 dlen 设为零)调用它,它会告诉你 *olen 里需要的空间大小,而不做任何实际工作;真正调用时,成功返回 0,输入有任何问题返回 MBEDTLS_ERR_BASE64_INVALID_CHARACTER(也就是 -0x002C),目标太小则返回 MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL(也就是 -0x002A)。解码后的长度落在 *olen 里,而且和 OpenSSL 的一次性函数不同,它永远是诚实的数字:解码 TQ== 给你一个字节,4d,再无其他。

它的输入规则是四家库中最严格的,值得背下来,因为它们定义了 Mbed TLS 眼中"合法"的含义:

  • CRLF 和 LF 换行可以出现在组与组之间 - 邮件载荷原样就能用。
  • 空格只允许出现在换行之前和缓冲区的最末尾;换行之后或组中间的空格是错误。
  • 至多两个 =,而且只能在末尾;填充之后还有任何数据都是错误。
  • 任何高于 127 的字节(重音符号、UTF-8 碎片、二进制垃圾)都是错误。

最后这条规则最咬人:如果载荷来自一个把字符编码弄乱了的来源,Mbed TLS 会拒绝它,而一个懒散的解码器本该耸耸肩把它解出来。对于任何要碰不可信输入的场景,严格就是特性。一个完整的解码长这样:

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

(尺寸查询返回"太小"这个码是有意为之:这是该函数报告它本会写入什么的方式。上面两个返回码都来自 <mbedtls/base64.h>,也就是声明该函数的那个头文件。)

APR-Util 和 GLib:另外两把椅子

APR-Util - Apache Portable Runtime 的实用库,也是 Apache HTTP Server 的地基 - 自服务器需要解码 Basic 认证头以来就一直携带着 Base64。它的 API 是一小族基于 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);

伸手去用它之前,先知道两件事。第一,长度是 int:32 位,所以实用的上限是每次调用 2 GB,对头部和配置值来说够用,要解码一个 4 GB 的文件就不行了。第二 - 也是大件事 - 解码函数根本没有错误返回。这个行为只存在于实现里,头文件里看不见:解码器把任何无效字符(包括空白和 NUL)都当作终止符。它解码到第一个不认识的东西为止,返回走了多远,然后什么都不说。一个被截断的载荷、一段末尾带着注释的粘贴、一个损坏在中间的字节 - 全都产生一个悄悄变短的输出。如果你使用 APR 的解码器,你必须拿返回的长度去核对载荷承诺的长度;函数不会替你干这件事。没有基于 pool 的封装 - 目标缓冲区由你提供,所以在 pool 驱动的代码里,你要自己从 pool 分配 plain_dst。这里还有一个你在本文其他地方找不到的 EBCDIC 角度:在 EBCDIC 机器上,这些函数在编码前把输入转成 ASCII,解码后再转回来,所以同一段代码能在那些仍跑着 httpd 的大型机上运行。

GLib,GTK 和大多数 GNOME 应用背后的运行时,是截然相反的性格。它的解码器接收一个字符串,永远交回一个新分配的缓冲区(只有你传入 NULL 指针时才返回 NULL),能解多少解多少,剩下的悄悄跳过:

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

陷阱就藏在"永远"这个词里。GLib 的解码器属于宽容学派:字母表之外的字符被跳过,而不是致命错误。喂给它 TWFuZ@==,它会交出 "Man" 的三个字节,连眉毛都不会抬一下。还有一个方便的原地变体 g_base64_decode_inplace(),它在输入缓冲区之上直接解码(因为输出比输入短,所以安全)并返回同一个指针,于是结果从缓冲区开头开始 - 对内存紧张的代码是个漂亮的技巧,而且它照样乐意吃掉 CRLF 折行的输入。给 C 开发者的要点:如果你的数据不可信,GLib 救不了你,它不会替你挡住损坏的载荷。_step 变体(g_base64_decode_step 加一个状态整数)在你需要增量解码时可用,而对应的 g_base64_encode_step/g_base64_encode_close 组合住在编码那一侧。

URL 安全 Base64:另一个字母表

在标准字母表和你的 URL 之间,有人受过伤。标准 Base64 用 + 和 / 作为它的两个最高符号,而这两个在 URL 里都是麻烦:查询字符串里的 + 到了你的服务器那里常常已经被解读成空格,/ 又是路径分隔符。RFC 4648 第 5 节定义了解法,叫 base64url:同样的编码,只是把 + 换成 -,/ 换成 _,并且当长度已经通过其他方式可知时,丢掉尾部的 = 填充。JSON Web Token、OAuth 状态参数,以及一大堆 API 会话 ID,都活在这个方言里。

这四个 C 库没有一个原生解码 base64url,所以转换是一个你写一次、到处复用的小组件:把两个特殊字符映射回去,补上缺失的填充,然后把结果交给你的标准解码器。先做长度检查,因为比四的倍数多一的长度在任何 Base64 方言里都不可能存在:

int base64url_decode(const char *url_safe, unsigned char *out,
    size_t out_cap, size_t *out_len) {
  size_t len = strlen(url_safe);
  if (len % 4 == 1) {
    return -1;
  }
  size_t needed = (len * 3) / 4;
  if (needed > out_cap) {
    return -2;
  }
  char *std = malloc(len + 4);
  if (std == NULL) {
    return -3;
  }
  for (size_t i = 0; i < len; i++) {
    char c = url_safe[i];
    if (c == '-') c = '+';
    if (c == '_') c = '/';
    std[i] = c;
  }
  size_t pad = (4 - len % 4) % 4;
  for (size_t i = 0; i < pad; i++) {
    std[len + i] = '=';
  }
  int n = EVP_DecodeBlock(out, (const unsigned char *)std,
                          (int)(len + pad));
  free(std);
  if (n < 0) {
    return -1;
  }
  *out_len = needed;
  return 0;
}

这条路有两个坑守着。第一个是方向:如果你不做字符交换就把 URL 安全载荷喂给标准解码器,OpenSSL 和 Mbed TLS 会拒绝它(那些字符不在它们的字母表里),而 GLib 会悄悄跳过 - 和 _,交回一个比该有的短的字符串 - 而且毫无错误。永远走组件这条路。第二个是 RFC 自己的警告,值得认真对待:base64url"不应被视为与 base64 编码相同"。如果一个载荷恰好不含 - 或 _,两个方言对这份数据是字节级相同的,混用是不可见的 - 而这正是混用能一直存活、直到撞上某个含有这类字符的载荷的原因。

文件:恢复原件

最常见的文件形任务是某些导出流程做过的反向操作:一个 .b64 文本文件送到你手上,你需要把原始文件拿回来。读入全部文本,解码,然后在相信任何标签之前,让字节自己表明身份。C 没有 finfo,所以实用的测试就是对前几个字节做魔数嗅探:

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

说说边缘:即使是文本那一半,也要用二进制模式(rb/wb)打开文件,因为文本模式在某些平台上会转换行尾,从而破坏你的字符计数;也永远不要对解码后的缓冲区用 printf("%s") 来"看看它是什么"。魔数嗅探才是问这个问题的诚实方式,而且如果你之后要把恢复出的文件发给浏览器,Content-Type 应该来自同一次嗅探,而不是来自文件名。

data URI:URL 里的那张图片

来自 web 世界的一个经典出场:有人把一张图片粘贴进表单,前端就交给了你的服务器一个完整的 data URI,像 data:image/png;base64,iVBORw0KGgo...。RFC 2397 定义了它的形状:data:,一个可选的媒体类型,一个可选的 ;base64 标志,一个逗号,然后是载荷。标志在场时,载荷是 Base64;标志缺席时,载荷是百分号编码的纯文本 - 少见,但合法。如果省略媒体类型,默认是 text/plain;charset=US-ASCII。在 C 里解析它,就是找到那个逗号,再看看它前面紧挨着什么:

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

调用方读起来像一句人话:

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);
  /* 现在用你自选的库解码 payload */
}

这个格式里住着三个坑。第一个是缺失的 ;base64 标志:没有它的合法 data URI 携带的是百分号编码的载荷,把它塞进 Base64 解码器只会产出垃圾 - 先检查标志,再选择解码器。第二个是声称的媒体类型:它是发送方的提示,不是事实;文件那一节的魔数嗅探才是你的事实。第三个是尺寸:RFC 自己的建议是 data URI 只用于短值,所以一张几兆的图片骑在 URL 里面,是你架构上的异味,而不是值得庆祝的模式。

JWT:阅读那些不保密的部分

web 上最有名的 Base64 载荷是 JSON Web Token,也是你认清它的形状之后最不吓人的一种。按照 RFC 7519,一个紧凑 JWT 是三段 base64url 用点连接:头部、载荷、签名 - 每一段都不带填充、不带换行。前两段是纯 JSON,这就是为什么人人能读它们,也是为什么人人都应该在碰令牌之前先读完下面的内容。

读前两段只需要上面 base64url 组件的几行代码,这也是给令牌祛魅最快的方式:

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

打印出来,头部是 {"alg":"HS256","typ":"JWT"},载荷是 {"sub":"1234567890","name":"John Doe"}。现在说要紧的部分:第三段是签名,而你刚解码出来的那两段既不保密,也没有经过认证。任何拿着抓包工具的人都能读它们,任何拿着文本编辑器的人都能改写它们。在验证签名之前信任 C 里的 JWT 载荷,是经典的认证 bug,而 Base64 让"没注意到"变得异常容易 - 令牌看起来像一块打不破的方块,其实是一张明信片。要验证 HS256 令牌,你用密钥对 header.part 重新计算 HMAC-SHA256,用 <openssl/hmac.h> 的 HMAC() 计算,再用 CRYPTO_memcmp() 做常量时间比较;如果摘要不一致,无论令牌声称什么,它都被拒绝。C 里没有事实标准的 JWT 库,所以生产环境里,要么你自己搭起那一小步验证,要么采用某个社区库 - 但这份工作的 Base64 部分就是上面的切分加解码之舞,你应该理解其中全部。

Basic 认证:那个从未学会隐私的头

web 上最古老的认证头依然骑在 Base64 上:Authorization: Basic 后面跟着 username:password 的标准字母表编码(RFC 7617,RFC 9110 为 Basic 方案引用了它)。RFC 明确说了,这是编码,不是保护 - 任何拿着抓包工具的人一条命令就能解码出两半 - 所以在 C 里解码这一侧的工作是:解析头,严格解码,在第一个冒号处切分(密码可以合法地包含冒号),然后用计时安全的函数比较:

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

那个长度检查干的是真活:它挡住了解码后是 "alice:secret" 但拖着尾部垃圾的载荷,或者 "alice:secre" 这种被截断的载荷去匹配。而 CRYPTO_memcmp(只有在你理解计时含义时才可以用 memcmp)挡住了攻击者通过计时摸清你的用户列表。要么在 HTTPS 下提供这个头,要么干脆不提供 - 在明文连接上,Base64 层只是表面装饰。

邮件与 PEM:最初的家园

Base64 诞生自一个非常具体的问题:邮件传输只带 7 位 ASCII,而人们想把二进制从它里面发出去。MIME(RFC 2045)让 Base64 成为标准传输编码之一,并加了两条家规:编码后的行不得超过 76 个字符,解码软件必须忽略字母表之外的字符 - 换行也不例外。正是第二条规则,让上面的流式解码器能零预处理地啃完一个折行的附件;也正是它,让 76 字符的习惯至今烧进地球上每一个邮件库。前辈是 PEM(Privacy Enhanced Mail,RFC 1421),它用的是 64 字符行 - 你在工具里看到的 64/76 分裂就是那段历史,两个上限归根结底都是 SMTP 强加的。

PEM 护甲 - 密钥和证书旅行的格式 - 就是带标签的 Base64:一行 -----BEGIN ... -----,64 字符一行的正文,再加一行对应的 END。在 C 里剥掉护甲就是逐行扫描,然后剩下的交给解码器:

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

解码出的字节是 DER,一种紧凑的二进制序列化,这也是 OpenSSL 的证书和密钥函数最终消费的东西。两个备注:收集正文时去掉换行(循环就是这么做的),这样你的长度才是四的倍数;如果一个文件携带多个块,要把 END 标签和你打开的那个 BEGIN 标签对上 - 当只要第一个块时,一个简单的标志就够用了,就像这里这样。

秘密、配置与数据库列

Base64 是一个文本容器,这就是为什么它总在你意想不到的地方出现。在配置文件和环境变量里,它是夹带那些会破坏格式的值的技巧:带分号的数据库 DSN、带引号的密码、带换行的值。在数据库里,一个二进制 blob 可以作为 Base64 住在文本列中,活过每一个假设那是文本的工具 - 不过有代价,大约多出三分之一的体积,所以给列定大小时要考虑到这一点(或者问问,为什么这个值干脆不放在 BLOB 列里)。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
static size_t real_length(const char *b64) {
  size_t len = strlen(b64);
  while (len > 0 && b64[len - 1] == '=') len--;
  return len * 3 / 4;
}
int main(void) {
  const char *b64 = getenv("API_KEY_B64");
  if (b64 == NULL) {
    printf("API_KEY_B64 is not set\n");
    return 1;
  }
  size_t cap = strlen(b64);
  unsigned char *out = malloc(cap);
  int n = EVP_DecodeBlock(out, (const unsigned char *)b64, (int)cap);
  if (n < 0) {
    printf("API_KEY_B64 is not valid base64\n");
    free(out);
    return 1;
  }
  size_t real = real_length(b64);
  printf("key is %zu bytes\n", real);
  free(out);
  return 0;
}

警告要讲两遍。第一,这是格式安全,不是秘密:一旦有开发者能读到配置文件,他一次调用就能解出那个值,而 RFC 的安全章节记录过真实事故:有人把一次协议交互报告给支持团队,就"意外暴露了密码",因为 Base64 是视觉上的伪装,不是计算上的保护。永远不要把一个秘密存成 Base64 然后管它叫加密。第二,在启动时校验:一个粘贴了一半的环境值会从严格调用里得到一个 -1,而一行检查能把三小时后那个玄奥的失败,变成启动时一条可执行的消息。

从 shell 里解码

不是所有解码都发生在你程序内部。CLI 脚本、cron 任务和一行流 不断地 在解码 Base64,C 开发者应该认识一下每台 Linux 机器上已经存在的两个工具。coreutils 的工具是通用的那一个:base64 -d 解码,-i 让它忽略垃圾字符而不是失败,-w 设置折行列宽(这只影响编码,不影响解码):

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

OpenSSL 自带一个,可以通过 openssl base64 访问(openssl enc -base64 的友好别名):

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

-A 标志的意思是"一行":编码时不做 64 字符折行,输入也预期是单行。而这里有一个 CLI 陷阱,如果你不读,它会花掉你一个晚上:OpenSSL 的 base64 解码是按行工作的,一个完全没有换行的载荷会解码成什么都不出来,而且是悄无声息的:

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

coreutils 的解码器没有这个预期,这也是它成为胶水工作里更安全的默认选项的原因之一。再多一句方言备注:BSD 血统的系统(尤其是较老的 macOS)历史上把解码标志拼成 -D;现代版本遵循 GNU 的 -d 约定,所以查一查你实际所在机器上的 man 页面。

大载荷,小内存

解码是帮你省的方向:输出是输入的四分之三,所以来自 Base64 的内存压力很少见。不过,当一个几百兆的 .b64 文件落到磁盘上时,前面的流式路径就是你的工具,而且它比看上去更简单。分块读取编码文件,把每块喂给 EVP_DecodeUpdate,解码出的字节一到就写出去。上下文会在各次调用之间保留任何未完成组的那一三个字符,所以块边界可以落在任何地方 - 你不需要对齐它们:

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

峰值内存是两块缓冲区,量级也就几十 KB,与文件大小无关,而且损坏的文件会快速失败 - EVP_DecodeUpdate 会在出问题的块上返回 -1,于是你可以报告一个偏移量,而不是耸耸肩。这条路径有一个库的注意事项:APR-Util 的解码器工作在 NUL 终止字符串上,记账是 int 大小(返回值是 int,输入还被一个内部常量卡在略低于 3 GB),所以多 GB 的文件它出局了。如果你需要进度报告,数一数你已写入的字节 - 那就是你在输出中的位置,而输入位置大约是它的四分之三。

陷阱集:清一色 C 特色

收集在一处,这些专属于在 C 里做这件事的陷阱:

  • 补零的一次性调用。 EVP_DecodeBlock 返回的是单元长度,不是数据长度。TQ== 报告三个字节,实际只带一个。永远从尾部填充重新计算真实长度,或者使用流式组合。
  • 解码出的字节不是字符串。 结果可能包含 NUL 字节,也可能不是 UTF-8。不能用 strlen,不能用 printf("%s"),不能把它传给任何假设那是文本的函数。到处都带着(指针、长度)走。
  • 缓冲区大小是你的活。 C 不会替你扩大输出缓冲区,解码器也不会 - OpenSSL 的 update 把它解码出来的东西写进你给的空间里。按 in_len * 3 / 4 + 3 来定大小(如果输入是折行的、而且你用一个不剥离换行的组件解码,还要加上折行开销),并且在每个封装里保留容量检查。
  • 带符号 char 查表。 如果你哪天自己写一个解码器,经典 bug 就是在 char 为带符号的平台上用普通 char 把输入字节当索引去查一张 256 项的表:字节 0xFF 变成 -1,你就朝内存反方向索引去了。永远用 unsigned char 或 unsigned 值做索引。
  • 沉默的那几个才危险。 APR-Util 在第一个无效字符处停下,什么都不说;GLib 跳过垃圾,什么都不说。OpenSSL 和 Mbed TLS 会大声失败。如果你的输入不可信,库的沉默就是你程序里的 bug,而不是库的 bug。
  • 命令行会吃掉换行。 openssl base64 -d 在输入没有换行时解码出零字节。剥离尾部换行的 shell 管道(tr -d '\n'、xargs、编辑器保存时不带末尾换行)会产出一个空的输出,毫无错误。
  • int 与 size_t。 OpenSSL 的一次性 API 接收 int 长度,APR-Util 通篇用 int,Mbed TLS 和 GLib 的 API 用 size_t。它们之间混合长度的运算,正是带符号/无符号警告藏匿真实 bug 的地方 - 也是 APR 的 2 GB 上限所在。
  • 空白并不统一。 OpenSSL 在任何位置跳过所有空白;Mbed TLS 允许组间的 CRLF/LF 和换行前的空格,但不允许换行后或行中;CLI 工具各不相同。一个对某个解码器合法的载荷,对另一个可能不合法,"在我的机器上是好的"通常意思是"我的解码器更懒"。

好习惯,收集起来

先验证再信任:一个形状检查(字母表字符、至多两个尾部填充)能在任何解码之前抓住明显的垃圾,但只有真正的解码才懂得 Base64 的语义,所以最终的裁判权在严格解码器手里。需要诚实长度或分块输入时,用 OpenSSL 的流式组合;载荷很小且你能立刻纠正其长度时,用一次性函数。把(指针、长度)成对保管,永远别让解码后的缓冲区遇上字符串函数。用 CRYPTO_memcmp 比较认证材料。相信文件名或声称的 MIME 类型之前,先嗅探魔数。还有,把 Base64 当作它本来的东西 - 一种包装格式,一个装字节的小盒子 - 而不是一把锁:这 64 个字母里的任何东西,都让你的数据变得不私密。

Base64 在 C 里的简史

故事始于邮件。1990 年和 1991 年,一群密码学家勾勒出 Privacy Enhanced Mail,一个带签名和加密的邮件系统,他们需要一种把二进制搬过 7 位网络的方式。他们的答案被标准化为 1993 年的 RFC 1421,按每字符 6 比特编码数据 - "base 64" - 用 64 字符的行,而实现当然是 C。差不多同一时间,web 带着它自己的 MIME 到来了,先是 RFC 1521(1993),然后是 RFC 2045(1996),沿用了同样的字母表,把行长放宽到 76,让 Base64 成为年轻互联网的附件格式。

C 的标准库错过了整艘船。C89 标准发布于 1990 年,比 MIME 还早三年,此后语言的委员会从未添加过 Base64 函数 - C99 没有,C11 没有,C23(2024 修订版)也没有。所以生态围绕这些库生长了起来:OpenSSL 自人们为 TLS 链接它以来,就在 libcrypto 里携带着 EVP 编解码例程;Mbed TLS(2015 年从 PolarSSL 更名)为嵌入式系统保留了一对小巧严格的函数;APR-Util 随 Apache 一起发布,因为服务器需要解码自己的认证头;GLib 则为桌面加上它的三件套。标准追赶着实现:2003 年的 RFC 3548 把旧定义整理干净,2006 年的 RFC 4648(Base-N 编码)把字母表、URL 安全变体和本文依赖的安全规则正式化。颇有意味的是,那个 RFC 的第 11 节指向一个 ISO C99 参考实现 - 标准自己的示例解码器就是用 C 写的,这足以说明这种格式栖居在什么地方。

趣闻:C 版

几条带着 C 味、单纯知道就好玩的冷知识:

  • 这个名字是数学,不是营销:每个输出字符恰好携带 6 比特,而 2 的 6 次方是 64。"Base64" 就是进制基数,直接念出来。
  • 字母表是 65 个字符,不是 64:64 个符号加上 =,RFC 4648 称它为"额外第 65 个字符",用于一种特殊处理功能。填充是个干活的,不是个字母。
  • OpenSSL 把编码输出在 64 字符处折行(PEM 的习惯),coreutils 在 76 处折行(MIME 的习惯)。这 12 个字符的差距,是两代邮件历史,你在同一台机器上两条命令的输出里就能看到。
  • GNU coreutils 的 base64 命令作者是 Simon Josefsson - 和写 RFC 4648 的是同一个人。标准和它使用最广的实现之一共享一位作者,这就是它们在每个边界情况上意见一致的原因。
  • Mbed TLS 通过常量时间辅助函数(mbedtls_ct_base64_*)做表查找,所以解码速度不会泄露它看到了哪些字符。一个你永远注意不到、却庆幸它存在的细节。
  • TQ== 是最小的非平凡载荷:一个真实字节,两个填充。它是完美的测试向量 - OpenSSL 的一次性解码器对它返回三个字节,流式解码器返回一个,Mbed TLS 返回一个,GLib 返回一个。四个库,两种答案,差别就是零填充。
  • APR 的 base64 函数是本文唯一在意 EBCDIC 的,因为 httpd 仍运行在字母不是 ASCII 的机器上。C 标准库从未见过大型机;APR 见过。
  • 空载荷是通用的单位元:每个库都把零长度输入编码和解码为零长度输出,毫无错误。如果你的解码器卡在空字符串上,你的是 bug,不是格式的问题。

翻到编码那一侧

这就是解码这一侧,而大部分痛都住在这里,因为解码是你遇见别人数据的地方:他们的填充选择、他们的换行、他们损坏的字节、他们的令牌。相反的方向 - 把字节变成 Base64 字符串 - 是更温顺的动物,也有自己的一批陷阱:精确的缓冲区算术、要不要折行的问题,以及落在每个发送者头上的体积账单。C 语言中的 Base64 编码在相关文章里有深入讲解,链接就在本页,它和本文的搭配方式,就像解码器与编码器的搭配:两篇都读完,两个方向就再也不会让你惊讶。

最后更新: 2026-09-08

相关文章: C 语言中的 Base64 编码:完整指南