魔鬼在细节中:Base64 你可能不知道的几个细节

Base64 是我们常用的编码方式,广泛用于邮件编码、数据签名/数据校验编码以及HTML/XML复杂数据编码。这本来是一个花两分钟了解一下就无需关注的技术,但是最近线上遇到一个相关问题。于是重新梳理了一下 Base64, 发现了一些以前未曾注意到的细节,记录如下,希望对你也有帮助。

Base64 是什么?

Base64 是一种将二进制数据表示为可打印字符的编码方法。基本操作是将3个字节编码为4个 Base64 单元:3 * 8 bit = 4 * 6 bit. 这种编码方法常用于处理文本数据的场合,例如在 HTML/XML 中表示、传输、存储一些二进制数据(如数据签名、数据校验等)。Base64 编码后数据增长为原来的 4/3 ≈ 1.33 倍。相较于将二进制数据按照十六进制输出数据增长为原来2倍,Base64 更加节省空间。Base64 的标准是 RFC 4648,如果你不想直接阅读这个拗口啰嗦的RFC,可以继续往下看。

Base64 不是什么?

Base64 不是一种加密方式,因此它不提供任何安全特性。我们在论坛、个人博客中发现很多人使用 Base64 编码显示自己邮箱主要是避免被搜索引擎及其他批量化工具发现和索引。

Base64 编码结果是唯一的吗?

不是的。Base64 根据编码字典表不同以及是否 padding (使用=作为 padding 字符),对同一数据的编码结果可能不同。使用最多的字典表有两个:

                      Table 1: The Base 64 Alphabet

     Value Encoding  Value Encoding  Value Encoding  Value Encoding
         0 A            17 R            34 i            51 z
         1 B            18 S            35 j            52 0
         2 C            19 T            36 k            53 1
         3 D            20 U            37 l            54 2
         4 E            21 V            38 m            55 3
         5 F            22 W            39 n            56 4
         6 G            23 X            40 o            57 5
         7 H            24 Y            41 p            58 6
         8 I            25 Z            42 q            59 7
         9 J            26 a            43 r            60 8
        10 K            27 b            44 s            61 9
        11 L            28 c            45 t            62 +
        12 M            29 d            46 u            63 /
        13 N            30 e            47 v
        14 O            31 f            48 w         (pad) =
        15 P            32 g            49 x
        16 Q            33 h            50 y

         Table 2: The "URL and Filename safe" Base 64 Alphabet

     Value Encoding  Value Encoding  Value Encoding  Value Encoding
         0 A            17 R            34 i            51 z
         1 B            18 S            35 j            52 0
         2 C            19 T            36 k            53 1
         3 D            20 U            37 l            54 2
         4 E            21 V            38 m            55 3
         5 F            22 W            39 n            56 4
         6 G            23 X            40 o            57 5
         7 H            24 Y            41 p            58 6
         8 I            25 Z            42 q            59 7
         9 J            26 a            43 r            60 8
        10 K            27 b            44 s            61 9
        11 L            28 c            45 t            62 - (minus)
        12 M            29 d            46 u            63 _
        13 N            30 e            47 v           (underline)
        14 O            31 f            48 w
        15 P            32 g            49 x
        16 Q            33 h            50 y         (pad) =

这两个字典表的区别主要是 6263 使用的字符不同(我们将这两个字符称为特殊字符)。因此,对于同一数据最多可能有 2 * 2 = 4 种编码结果。以 0x0F0xF1(2 bytes)为例,有以下4种编码结果:

  • 字典表1 + padding: D/E=
  • 字典表1 + nopadding: D/E
  • 字典表2 + padding: D_E=
  • 字典表2 + nopadding: D_E

这种一个数据有多个编码结果的情况,往往会给我们解码带来困扰。因此,在使用 Base64 的场景中,务必在文档中注明你是使用的哪一个字典表以及是否需要 padding. 当然,国内环境对文档普遍不够重视,在这么小的技术点上写如此细致是不敢奢望的,有一条有用的经验是:在没有特殊说明的情况下,技术文档中的 Base64 一般是指 字典表1 + padding.

Base64 是 url/filename safe 的吗?

如上所诉,我们默认的 Base64 编码使用的是字典表1,而这个字典表中的字符 +/ 无论是在url还是文件系统中都是特殊字符。因此,基于字典表1的Base64编码不是 url/filename safe 的,不能将该 Base64 编码直接与url拼接或用来命名文件。基于字典表2的 Base64 编码是 url/filename safe 的。但它不是我们大部分编程语言的默认字典,因此,你如果选择这个字典进行 Base64 编码,在解码时也选择该字典。否则你有很大概率会遇到部分数据能解码,部分数据不能解码的问题。

Base64 可以自定义特殊字符吗?

字典表2使用了不一样的 6263 特殊字符以实现 url/filename safe. 但这可能无法满足所有应用场景。因此根据 RFC, 你是可以自定义这两个特殊字符建立自己的字典表的。有一点需要注意,如果你使用了自定义字典表,那么请确保自己编码和解码使用的字典表是一致的。

Base64 编码结果中的等号(=)可以省略吗?是多余的设计吗?

可以省略,但不是多余的设计。

我们先看为何可以省略:

对于数据 A, 如果我们省略padding的等号,解码的时候我们从QQ是可以推断出来,原始数据长度必然是1 byte, 因此可以可以正确解码。数据 BC 同理。

既然 padding 的等号完全不影响解码,是否可以取消这个设计呢?答案是否定的。对于一些将多个Base64编码拼接在一起的场景,padding的等号可以标记一个 Base64 编码单元的边界,使得拼接后的 Base64 编码整体是可以无歧义正确解码的。如果省略等号,则无法保证无歧义性。我们看一个例子:

  • I Base64编码为 SQ (SQ== with padding)
  • AM Base64编码为 QU0 (QU0= with padding)
  • Daniel Base64编码为 RGFuaWVs (RGFuaWVs with padding)

如果使用省略等号的方式,拼接后的Base64编码是 SQQU0RGFuaWVs, 因为我们无法区分边界,我们只能对整个字符串进行解码,显然解码结果是不正确的。如果我们不省略等号,则拼接后的编码 SQ==QU0=RGFuaWVs 可以根据等号区分边界,然后分块正确解码。

扩展阅读

What a May Day

周四晚上跟生日的父母吃过晚饭后,傻乎乎的带着小梦梦在孩子王逛,一点没有意识到已经是五月的最后一天。有时候,记录的习惯更多的就是提醒自己,时间的昼夜不舍。

这个月有意识的接触了挺多人,对于鄙人这种社交贫瘠的人来说,这个月花在这方面的时间算是奢侈的了。有老友也有新朋友。一次跟新朋友印象深刻的夜谈。已经很久没有跟新认识的人如此没有负担的沟通和交流了。对了,上一次有意思的聊天也是去年的这个时候。初夏,真是一个神奇的时节,一切都开始要变得明亮而耀眼。

五月在做和要做的事情越来越多,一种此情无计可消除,才下眉头,却上心头之感。一部分算是甜蜜的负担,而立之年,一些事情逐渐跟自己是否准备好已经没有必然关系,而是直接去解决它就对了。一部分是多过去时光的辜负,亡羊补牢,希望犹未晚矣。前天看韩老师的5X兴趣社区,看到李伟龙一个视频的幕后花絮,对话挺走心的:时间只会让你老去,其他什么都不会带来;只有你想改变的时候,你才能改变。

神奇的五月,居然达成了跑渣的第一次5公里(一个都不好意思提的配速)。从去年4月份参加跑团开始跑步,到现在已经一年多了,跑步成绩上没有任何提升,也是我预料之中的。对于这件事,我其实想得很明白:我一点都不喜欢运动,但是要支撑我的情怀和要做的事情,我必须要有这个练习和准备。显然,如果保持当前的做事的节奏,也许一周一次的跑步很快就无法支撑自己在做的事情,但是只要保持这件事情的惯性,我相信这股力量不会让自己失望。

这个月最喜欢的书是吴军老师的《智能时代》。因为一直在订阅吴军老师在得到的专栏,因此书中的很多内容其实都在专栏中听过了(如此说来,维护专栏及时高产如母猪,也是需要有存货当备份的?)。有两点体会最深:

  1. 人类文明发展是一个不短加速的过程,每一次加速都会让已有产业与新结束结合形成形成新的产业,赶上这个浪潮的会以数量级的优势领先,赶不上或者不愿拥抱变革的则会被无情的淘汰。
  2. 大数据和AI是当前最有可能成为下一个时代的蒸汽机和电。超越时代是困难的,但是从思维方式上则是可以刻意练习大数据和AI思维的。对于程序员而言,这尤为重要——有很大可能性,这决定了当前的你是成为为工业时代的码农,还是智能时代的工程师。

六月会迎来自己在两个月前设定的一个deadline, 从目前看来,不容乐观。可能当时在设定这个目标的时候,其实内心的真实独白就已经是法乎其中则得其下,法乎其上则得其中。但是,总的来说,过去的两个月无论是还在发生还是已经发生的事,多少带来了一丝丝改变。

期待六月,不负好时光。

本站开启支持 QUIC 的方法与配置

在越来越讲究用户体验的今天,网络带宽的提高已经很难有显著的页面加载改善,而低延迟的优化往往能够起到意想不到的效果。在《TLS1.3/QUIC 是怎样做到 0-RTT 的》中我们分析了TLS1.3和QUIC在低延迟方面的原理和低延迟优势。在从源代码编译 nginx docker 镜像开启 TLS 1.3中我们已经把玩了TLS1.3,没有理由不把玩一下QUIC,对吧?

起初以为,在普及程度上,QUIC因为主要是Google主导,会曲高和寡。但是,查了一下,发现腾讯早在2017年就在生产环境应用了QUIC:让互联网更快的协议,QUIC在腾讯的实践及性能优化. 结果显示:

灰度实验的效果也非常明显,其中 quic 请求的首字节时间 (rspStart) 比 http2 平均减少 326ms, 性能提升约 25%; 这主要得益于 quic 的 0RTT 和 1RTT 握手时间,能够更早的发出请求。

此外 quic 请求发出的时间 (reqStart) 比 h2 平均减少 250ms; 另外 quic 请求页面加载完成的时间 (loadEnd) 平均减少 2s,由于整体页面比较复杂, 很多其它的资源加载阻塞,导致整体加载完成的时间比较长约 9s,性能提升比例约 22%。

既然大厂都已经发车,我司也就可以考虑跟进了。稳妥起见,决定先在自己的博客开启QUIC,然后再逐步在线上业务进行推广。

方案概览

方案非常简单:不支持QUIC的浏览器依旧通过nginx tcp 443访问;支持QUIC的浏览器通过caddy udp 443访问。

由于nginx近期没有支持QUIC的计划, 作为一名gopher, 因此这里选择caddy作为QUIC的反向代理。后面会介绍caddy的具体安装和配置方法。

对于支持QUIC的浏览器来说,第一次访问支持QUIC的网站时,会有一次服务发现的过程。服务发现的流程在QUIC Discovery
有详细介绍。概括来说,主要有以下几步:

  1. 通过TLS/TCP访问网站,浏览器检查网站返回的http header中是否包含alt-svc字段。
  2. 如果响应中含有头部:alt-svc: 'quic=":443"; ma=2592000; v="39"',则表明该网站的UDP 443端口支持QUIC协议,且支持的版本号是draft v39; max-age为2592000秒。
  3. 然后,浏览器会发起QUIC连接,在该连接建立前,http 请求依然通过TLS/TCP发送,一旦QUIC连接建立完成,后续请求则通过QUIC发送。
  4. 当QUIC连接不可用时,浏览器会采取5min, 10min的间隔检查QUIC连接是否可以恢复。如果无法恢复,则自动回落到TLS/TCP。

这里有一个比较坑的地方:对于同一个域名,TLS/TCP和QUIC必须使用相同的端口号才能成功开启QUIC。没有什么特殊的原因,提案里面就是这么写的。具体的讨论可以参见Why MUST a server use the same port for HTTP/QUIC?

从上面QUIC的发现过程可以看出,要在网站开启QUIC,主要涉及两个动作:

  1. 配置nginx, 添加alt-svc头部。
  2. 安装和配置QUIC反向代理服务。

配置nginx, 添加alt-svc头部

一行指令搞定:

安装QUIC反向代理服务器caddy

上面我们提到对于同一个域名,TLS/TCP和QUIC必须使用相同的端口号才能成功开启QUIC。然而,caddy服务器的QUIC特性无法单独开启,必须与TLS一起开启,悲剧的是TLS想要使用的TCP 443端口已经被nginx占用了?

场面虽然有点尴尬,但是我们有docker:将caddy安装到docker中,然后只把本地的UDP 443端口映射到容器中即可。

于是我们创建了一个docker-caddy项目。Dockerfile 10行内搞定:

caddy 服务配置文件/conf/blog.conf:

启动docker:

开启Chrome浏览器QUIC特性

chrome://flags/中找到Experimental QUIC protocol, 设置为Enabled. 重启浏览器生效。

测试QUIC开启状态

重新访问本站https://liudanking.com, 然后在浏览器中打开:chrome://net-internals/#quic。如果你看到了QUIC sessins,则开启成功:

当然,你也可以给Chrome安装一个HTTP/2 and SPDY indicator(An indicator button for HTTP/2, SPDY and QUIC support by each website) 更加直观的观察网站对http/2, QUIC的支持情况。