Base64 不是加密:字母表、填充与真实传输故障
Base64 比 HTTP 还老。1989 年它出现在 RFC 1113 里,为一个早已失败的邮件加密项目(PEM)服务;两年后被 MIME 标准化。它存在的目的远比今天的用法狭窄:当年的 SMTP 协议只能传 7-bit ASCII,二进制(图片、附件、密钥)必须想办法塞进这条 7-bit 通道。Base64 就是当年最后胜出的偷渡方案。
今天大部分 Base64 用法其实都在重复 1989 年那件事:把二进制塞进只懂文本的通道。CSS 里嵌一张 PNG、JWT 头部里塞一段 JSON、webhook 在字符串里夹一段二进制 payload——本质都是这件事。
它到底是什么
Base64 把 3 个字节(24 bit)重新表达成 4 个字符,这 4 个字符来自一个 64 个符号的字母表:A–Z、a–z、0–9 加上两个特殊字符。24 bit 切成 4 组 6 bit,每组 6 bit 正好是 64 种可能,"64"这个数字就是这么来的。代价是输出比输入大 33%——这是用纯可打印 ASCII 表达任意字节的固定成本。
如果输入字节数不是 3 的整数倍,编码器会用 1 到 2 个 = 把最后一组补齐,让输出长度永远是 4 的倍数。整个机制就这些。没有更多花活。
也就是说,把 Base64 叫做"加密"的人,错得有点尴尬。Base64 是一个语法变换,字母表是公开固定的。任何人盯着 aGVsbG8= 看一会儿就能在心里把它解出来,任何解码器都能在微秒级完成。它不藏任何东西。
两套字母表
RFC 4648 §4 定义的标准 Base64 用 + 和 / 作为两个非字母数字符号,= 做填充。在邮件和大多数数据格式里这没问题,但 +、/、= 在 URL 里都有特殊含义——+ 在某些场景被解析成空格、/ 是路径分隔符、= 在查询串里是保留字符。把标准 Base64 直接放进 URL,需要再做一层百分号编码,否则就会出错——而大多数人不会做。
同一份 RFC 在 §5 定义了 Base64url:把 + 换成 -、/ 换成 _,并允许省略填充。JWT、WebAuthn、OAuth state、绝大多数现代 API 都用 Base64url,原因正是上面那条。两套字母表互不兼容:用标准 Base64 解码器去解 Base64url 字符串,运气好出乱码,运气差直接报错(取决于解码器是否严格校验字母表)。
如果你拿到一个看起来像 Base64 的串解不出来,第一步永远是切换字母表试试。
它最常被滥用的地方
我见过最多的反模式:有人想把一个 JSON 对象塞进另一个 JSON、或者塞进 header、或者塞进 URL 参数。为了避开转义,他把这段 JSON Base64 编一下。
这能跑,但几乎总是错的选择。接收方现在要先 Base64 解码再 JSON 解析,错误被埋深了一层;payload 多出 33% 的体积;语义可读性归零。JSON 嵌 JSON 应该是带正确转义的字符串,header 应该用结构化字段,URL 参数应该用百分号编码。
只有数据本来就是二进制时——公钥、证书、图片字节、加密 blob——Base64 才是正确工具。对于本来就是文本、又走文本通道的数据,Base64 是把"开销"伪装成"修复"。
第二个常见滥用是 data URI。把一张 200KB 的图片以 data:image/png;base64,... 内联到 HTML 或 CSS,会让 200KB 变 270KB,并且这张图永远不会被浏览器单独缓存。极小图标(< 4KB 量级)省下一次 HTTP 请求时是合理的;大图直接就是反优化。
真实环境下解码失败的几种姿势
实际拿到的 Base64 串里掺杂的奇形怪状超出多数人的想象:
- 空白字符。RFC 4648 说解码器可以忽略换行和空格,注意是 MAY 不是 MUST。多数解码器忽略,但不是全部。从邮件里复制出来的 Base64 经常解不出来,原因就在这里——Outlook 在第 76 列处插 CRLF。PEM 文件也是故意分行的。
- 错的字母表。标准 vs URL-safe,两者悄悄地不兼容。
- 缺失或多余的填充。严格的解码器拒绝
aGVsbG8(无填充),宽松的接受。Base64url 经常完全省略填充,所以一个要求填充的 Base64url 解码器是写错了。 - 非规范编码。当输入字节数不是 3 的倍数,最后一组里会有不用的 bit 位。RFC 4648 要求编码器把这些 bit 位置零;不是所有编码器都遵守。这意味着两个编码器可能对同一份输入产生两个都"合法"的 Base64 串。绝大多数解码器都接受。但安全敏感的场景(签名 JWT、去重 key)应该拒绝非规范编码,否则有 malleability 攻击空间。
如果一个 Base64 串解不出来,按这个清单跑一遍就能修复绝大多数情况:先 tr -d '[:space:]' 去空白,再把 -_ 换回 +/,再把长度补齐到 4 的倍数(用 =)。
它不做的事
- 它不加密。(值得说两遍。)
- 它不压缩。输出永远比输入大 33%。
- 它不解决字符编码问题。如果你有一段 UTF-8 字节再 Base64,得到的是"UTF-8 字节的 Base64 串"——接收方拿到后还得先 Base64 解、再 UTF-8 解。
- 它不让二进制"安全可打印"。Base64 串可以放进 7-bit ASCII 通道和大多数文本字段,但
+、/、=仍然会在 shell 引号、SQL 标识符、URL 解析里翻车。
实用规则
- URL、header、文件名、所有"路径形状"的载体:用 Base64url。
- 邮件、MIME、证书、PEM:用标准 Base64。
- 不要为了避开转义,把已经在文本通道里的文本数据再 Base64 包一层。该转义的层是哪一层就在哪一层做。
- 不要把超过几 KB 的资源做成 data URI。
- 解不出来时先换字母表,再断言数据损坏。
- 永远不要指望 Base64 隐藏什么。它是传输约定,不是秘密。
主要参考资料
用于核对本文技术细节的标准与官方文档。
在浏览器里直接试
本站 Base64 工具支持两套字母表的本地编码 / 解码。当某个看起来是 Base64 的串解不出来时,先试试切换字母表,再判断它是不是真的损坏。所有处理都在浏览器里,数据不会发到任何服务器。
打开 Base64 工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。