深度解密 base64 字符串的编解码原理

发布时间 2023-06-20 18:29:55作者: 古明地盆

什么是 base64

我们知道一个字节可以表示的范围是 0 ~ 255,并且在 ASCII 码表中会对应一个字符,比如:字符 97 对应字符 'a'、90 对应字符 'Z' 等等。而在 ASCII 码表中有很多字符都是不可见字符,那么当数据在网络上传输时,由于不同设备对字符的处理会有一些不同,那些不可见字符就有可能被错误处理,所以这是不利于数据传输的。而解决办法就是先将数据进行一个 base64 编码,将其统统变成可见字符,这样出错的概率就大大降低了。

base64 应用在很多领域当中,比如:根证书、电子邮件、图片传输等等,那么下面就来分析一下 base64 到底是如何对数据进行编码的。

这里需要提一嘴,很多人会把编码(encode)和加密(encrypt)搞混,尽管它们都是将数据变成另一种格式,但两者还是不一样的。首先编码是为了方便数据传输,不是为了保证数据的机密性,比如这里的 base64,它的编码规则是公开的,只要你的数据是 base64 编码之后的,那么任何人都可以进行解码;而加密才是为了数据不被别人知道,所采取的安全策略。

base64 原理解密

我们说由于存在不可见的字符的问题,所以需要将每一个 ASCII 码都映射成可见字符。那么同 ASCII 码表一样,base64 肯定也有一张相应的表,记录了数字和字符之间的映射关系。

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

可以看到总共由 26 个大写英文字符、26 个小写英文字符、10 个阿拉伯数字、1 个加号、1 个斜杠,总共 64 个字符组成,所以是 base64。这些字符很明显都是我们熟知的可见的字符,任何设备对它们的处理显然都是没有歧义的。

我们以字节串 b"satori" 为例,看看它 base64 编码之后的值是多少?

import base64

s = b"satori"
print(base64.b64encode(s))  # b'c2F0b3Jp'

但是问题来了,到底要怎么映射呢?下面我们来解释一下。

第一步

将待转换的字节串每三个字节分为一组,由于每个字节有 8 位,那么总共是 24 个位。

s = b"satori"
print([c for c in s])
"""
[115, 97, 116, 111, 114, 105]
"""

以上是每个字节对应的 ASCII 码,我们转成二进制格式。

第二步

然后第一步中的 24 个位再每 6 个分为一组,因此可以分为四组。

因此总共我们得到了 8 组数据:011100、110110、000101、110100、011011、110111、001001、101001。

第三步

每组数据有 6 个位,然后在高位补两个 0,于是可以得到:00011100、00110110、00000101、00110100、00011011、00110111、00001001、00101001。

将其转成十进制,得到对应的值:

print(
    [0b00011100, 0b00110110, 0b00000101, 0b00110100,
     0b00011011, 0b00110111, 0b00001001, 0b00101001]
)  # [28, 54, 5, 52, 27, 55, 9, 41]

8 组数据对应的十进制数为 28、54、5、52、27、55、9、41。

第四步

根据每组得到的码值在 base64 编码对照表中映射出对应的字符,28 对应 c、54 对应 2、5 对应 F、52 对应 0、27 对应 b、55 对应 3、9 对应 J、41 对应 p,因此最终得到的结果就是 c2F0b3Jp,和 Python 计算的结果是一样的。

关于为什么要 3 个字节分为一组,原因是 6 和 8 的最小公倍数是 24,而 3 个字节正好是 24 个位。

整体来说还是比较简单好理解的,就是将原来的一组 3 个字节变成 4 个字节(所以 base64 编码之后的数据大小会比原来多大约三分之一)。并且此时每个字节只用了 6 个位(前两位补 0),取值范围正好是 0 ~ 63,和 base64 编码对照表匹配。

但是这里还存在一个问题,就是我们上面的字节串正好是 6 个字节,是 3 的倍数。那如果不是 3 的倍数怎么办?假设字节串只有一个字节,比如:b"A",对应的 ASCII 码为 65,二进制格式为 01000001。

即使只有 1 个字节,还是当成 3 个字节来处理,而多余的两个字节暂时先不管,然后依旧分为 4 组。第一组是 010000,前面补 0 之后得到 00010000、对应的结果为 16,根据 base64 对照表我们得到对应的字符为 Q;第二组只有 01,后面没东西了,那么此时后面全部按 0 处理,即 010000,然后前面补 0 得到 00010000,因此对应的字符也是 Q;而第三组和第四种组则是完全没有内容,这种情况直接对应 =。因此 b"A" 在 base64 编码之后得到的结果就是 QQ==,同理 b"satoriA" 在 base64 编码之后得到的结果就是 c2F0b3JpQQ==

import base64

print(base64.b64encode(b"satori"))  # b'c2F0b3Jp'
print(base64.b64encode(b"A"))  # b'QQ=='
print(base64.b64encode(b"satoriA"))  # b'c2F0b3JpQQ=='

除了一个字节之外,还有一种特殊情况,就是每 3 个字节一组,但只有两个字节,显然两者是类似的。我们还是来分析一下,以 b"satoriAV" 为例,显然我们只需要分析 AV 即可。

第一组:补 0 之后 00010000 对应十进制为 16,根据对照表得到字符 Q;第二组:补 0 之后 00010101 对应十进制为 21,根据对照表得到字符 V;第三组:补 0 之后 00011000 对应十进制为 24,根据对照表得到字符 Y;第四组全部为空,因此直接得到 =。所以 b"AV" 在 base64 编码之后对应的字符为 QVY=

import base64

print(base64.b64encode(b"satori"))  # b'c2F0b3Jp'
print(base64.b64encode(b"AV"))  # b'QVY='
print(base64.b64encode(b"satoriAV"))  # b'c2F0b3JpQVY='

所以假设原始字节串长度为 N,那么编码成 base64 之后的字节串长度 M 就等于 N * 8 // 6,当然这是在 n 是 3 的倍数的情况下,如果不是的话。

  • 如果 N % 3 == 1,即 N * 8 % 6 == 2,那么 M 要在原来的基础上再加上 3
  • 如果 N % 3 == 2,即 N * 8 % 6 == 4,那么 M 要在原来的基础上再加上 2

以上就是 base64 的原理,当然我们除了可以进行编码、也可以进行解码,不过是把过程逆过来了而已。base64 编码数据主要是为了传输和存储,正如我们最开始所说,base64 只能说是一种编码、不能算是加密。

C 语言实现 base64

绝大部分语言都内置了 base64 相关的库,可以直接对数据进行编码和解码,那么我们可不可以自己实现呢?显然是可以的,我们来试一下,由于 Python 太简单了,所以这里我们就用 C 来实现。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// base64 映射表
static const unsigned char base64_enc_map[64] =
{
    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
    'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',
    'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd',
    'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
    'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
    'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7',
    '8', '9', '+', '/'
};

/* 将字符串 data 编码成 base64 格式的字符串
 * @data: 原始字符串
 * @len: 原始字符串的长度,不包括 \0
 */
unsigned char * base64_encode(const unsigned char *data, size_t len)
{   
    size_t i, n;
    int c1, c2, c3;
    unsigned char *b64_data, *p;
    // 如果长度为 0,直接返回空字符串
    if (len == 0) {
        b64_data = (unsigned char *)malloc(1);
        b64_data[0] = 0;
        return b64_data;
    }
        
    // 长度为 len 的字符串,总共有 len * 8 个位,再除以 6,便可计算出要分多少组
    n = (len << 3) / 6;
    switch ((len << 3) - n * 6) {  // 等价于 len * 8 % 6
        case 2:
            n += 3;
            break;
        case 4:
            n += 2;
            break;
        default:
            break;
    }
    // 为 base64 字符串申请空间
    b64_data = (unsigned char *)malloc(n + 1);
    memset((void *) b64_data, 0, n + 1);
    // 遍历原始字符串,一次遍历 3 个字节
    // 如果原始字符串的长度少于 3 字节,那么就无需遍历了
    // 所以中止条件是 i < len / 3 * 3
    for (i = 0, p = b64_data; i < len / 3 * 3; i += 3) {
        c1 = *data++;
        c2 = *data++;
        c3 = *data++;
        // 3 个字节,24 个位,每 6 个为一组
        // 用 c1 的前 6 个位作为 8 位整数(高位补零)
        *p++ = base64_enc_map[c1 >> 2];
        // 用 c1 的后 2 个位和 c2 的前 4 个位组合作为 8 位整数(高位补零)
        *p++ = base64_enc_map[((c1 & 0b11) << 4) + (c2 >> 4)];
        // 用 c2 的后 4 个位和 c3 的前 2 个位组合作为 8 位整数(高位补零)
        *p++ = base64_enc_map[((c2 & 0b1111) << 2) + (c3 >> 6)];
        // 用 c3 的后 6 个位作为 8 位整数(高位补零)
        *p++ = base64_enc_map[c3 & 0b111111];
    }
    // 如果 len 是 3 的倍数,那么此时 i 应该和 len 相等
    // 如果 len 不是 3 的倍数,那么 i = len - 1 或 len - 2
    if (i < len) {
        c1 = *data++;
        // 如果 i == len - 2,说明多出来两个字节,赋给 c1 和 c2
        // 否则多出来一个字节(此时 c2 == 0)
        c2 = i == len - 2 ? *data++ : 0;
        *p++ = base64_enc_map[c1 >> 2];
        *p++ = base64_enc_map[((c1 & 0b11) << 4) + (c2 >> 4)];
        // 如果多出来两个字节,那么使用 c2 的后 4 个位,否则用 '=' 填充
        if (i == len - 2) 
            *p++ = base64_enc_map[(c2 & 0b1111) << 2];
        else
            *p++ = '=';
        // 原始字符串的三个字节,编码之后会对应四个字节,所以还差一个 '='
        *p++ = '=';
    }
    return b64_data;
}          

int main(void) {
    char *s1 = "satori";
    char *s2 = "satoriA";
    char *s3 = "satoriAV";
    printf("%s\n", base64_encode((const unsigned char *)s1, strlen(s1)));
    printf("%s\n", base64_encode((const unsigned char *)s2, strlen(s2)));
    printf("%s\n", base64_encode((const unsigned char *)s3, strlen(s3)));
    /*
    c2F0b3Jp
    c2F0b3JpQQ==
    c2F0b3JpQVY=
    */
    // free
}

以上就是 base64 的编码过程,再来看看解码,直接反过来就行。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 从原始字符串到 base64 字符串的映射关系为
// 0 -> 'A',1 -> 'B',2 -> 'C',······,62 -> '+',63 -> '/',使用数组再合适不过
// 现在则是反过来,变成 'A' -> 0,'B' -> 1,'C' -> 2,····,'+' -> 62,'/' -> 63
// 要维护这个映射关系,采用字典是最合适的,但 C 里面没有字典这种复杂的数据结构,所以需要另想办法
// 由于 C 里面字符就是一个整数,所以依旧可以使用数组
// 比如 '+',它就是一个整数,值为 43,由 62 映射得到
// 那么在下面这个数组中,索引为 43 的元素就是 62,剩余用不到的元素就用 255 代替
static const unsigned char base64_dec_map[128] = {
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255,  62, 255, 255, 255,  63,  52,  53,
     54,  55,  56,  57,  58,  59,  60,  61, 255, 255,
    255,  64, 255, 255, 255,   0,   1,   2,   3,   4,
      5,   6,   7,   8,   9,  10,  11,  12,  13,  14,
     15,  16,  17,  18,  19,  20,  21,  22,  23,  24,
     25, 255, 255, 255, 255, 255, 255,  26,  27,  28,
     29,  30,  31,  32,  33,  34,  35,  36,  37,  38,
     39,  40,  41,  42,  43,  44,  45,  46,  47,  48,
     49,  50,  51, 255, 255, 255, 255, 255
};

unsigned char *base64_decode(const unsigned char *b64_data, size_t len) {
    size_t i, n;
    int c1, c2, c3, c4;
    unsigned char *data, *p;

    if (len == 0) {
        data = (unsigned char *)malloc(1);
        data[0] = 0;
        return data;
    }
    // base64 字符串的长度除以 4、再乘以 3,即可得到原始字符串的长度
    n = len / 4 * 3;
    data = (unsigned char *)malloc(n + 1);
    memset((void *)data, 0, n + 1);
    // 每 4 个字节为一组,
    for (i = 0, p = data; i < len; i += 4) {
        c1 = base64_dec_map[*b64_data++];
        c2 = base64_dec_map[*b64_data++];
        c3 = *b64_data == '=' ? 0 : base64_dec_map[*b64_data++];
        c4 = *b64_data == '=' ? 0 : base64_dec_map[*b64_data++];
        *p++ = (c1 << 2) + ((c2 & 0b110000) >> 4);
        *p++ = ((c2 & 0b1111) << 4) + ((c3 & 0b111100) >> 2);
        *p++ = ((c3 & 0b11) << 6) + c4;
    }
    return data;
}

int main(void) {
    char *s1 = "c2F0b3Jp";
    char *s2 = "c2F0b3JpQQ==";
    char *s3 = "c2F0b3JpQVY=";
    printf("%s\n", base64_decode((const unsigned char *)s1, strlen(s1)));
    printf("%s\n", base64_decode((const unsigned char *)s2, strlen(s2)));
    printf("%s\n", base64_decode((const unsigned char *)s3, strlen(s3)));
    /*
    satori
    satoriA
    satoriAV
    */
}

以上我们就用 C 实现了字符串的 base64 编解码,还是不难的。

封装 Python 扩展

我们将上面实现的的功能封装成 Python 扩展,然后暴露给 Python,并测试它和内置的 base64 模块之间的性能差异。需要用到以下几个文件:

b64module.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static const unsigned char base64_enc_map[64] = {
    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
    'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',
    'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd',
    'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
    'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
    'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7',
    '8', '9', '+', '/'
};

static const unsigned char base64_dec_map[128] = {
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
    255, 255, 255,  62, 255, 255, 255,  63,  52,  53,
     54,  55,  56,  57,  58,  59,  60,  61, 255, 255,
    255,  64, 255, 255, 255,   0,   1,   2,   3,   4,
      5,   6,   7,   8,   9,  10,  11,  12,  13,  14,
     15,  16,  17,  18,  19,  20,  21,  22,  23,  24,
     25, 255, 255, 255, 255, 255, 255,  26,  27,  28,
     29,  30,  31,  32,  33,  34,  35,  36,  37,  38,
     39,  40,  41,  42,  43,  44,  45,  46,  47,  48,
     49,  50,  51, 255, 255, 255, 255, 255
};

/* 将字符串 data 编码成 base64 格式的字符串 */
unsigned char *base64_encode(const unsigned char *data, size_t len) {
    size_t i, n;
    int c1, c2, c3;
    unsigned char *b64_data, *p;
    if (len == 0) {
        b64_data = (unsigned char *)malloc(1);
        b64_data[0] = 0;
        return b64_data;
    }

    n = (len << 3) / 6;
    switch ((len << 3) - n * 6) {
        case 2:
            n += 3;
            break;
        case 4:
            n += 2;
            break;
        default:
            break;
    }
    b64_data = (unsigned char *)malloc(n + 1);
    memset((void *) b64_data, 0, n + 1);
    for (i = 0, p = b64_data; i < len / 3 * 3; i += 3) {
        c1 = *data++;
        c2 = *data++;
        c3 = *data++;
        *p++ = base64_enc_map[c1 >> 2];
        *p++ = base64_enc_map[((c1 & 0b11) << 4) + (c2 >> 4)];
        *p++ = base64_enc_map[((c2 & 0b1111) << 2) + (c3 >> 6)];
        *p++ = base64_enc_map[c3 & 0b111111];
    }
    if (i < len) {
        c1 = *data++;
        c2 = i == len - 2 ? *data++ : 0;
        *p++ = base64_enc_map[c1 >> 2];
        *p++ = base64_enc_map[((c1 & 0b11) << 4) + (c2 >> 4)];
        if (i == len - 2)
            *p++ = base64_enc_map[(c2 & 0b1111) << 2];
        else
            *p++ = '=';
        *p++ = '=';
    }
    return b64_data;
}

/* 将 base64 格式的字符串 b64_data 解码成原始字符串 */
unsigned char *base64_decode(const unsigned char *b64_data, size_t len) {
    size_t i, n;
    int c1, c2, c3, c4;
    unsigned char *data, *p;

    if (len == 0) {
        data = (unsigned char *)malloc(1);
        data[0] = 0;
        return data;
    }
    n = len / 4 * 3;
    data = (unsigned char *)malloc(n + 1);
    memset((void *)data, 0, n + 1);
    for (i = 0, p = data; i < len; i += 4) {
        c1 = base64_dec_map[*b64_data++];
        c2 = base64_dec_map[*b64_data++];
        c3 = *b64_data == '=' ? 0 : base64_dec_map[*b64_data++];
        c4 = *b64_data == '=' ? 0 : base64_dec_map[*b64_data++];
        *p++ = (c1 << 2) + ((c2 & 0b110000) >> 4);
        *p++ = ((c2 & 0b1111) << 4) + ((c3 & 0b111100) >> 2);
        *p++ = ((c3 & 0b11) << 6) + c4;
    }
    return data;
}

b64module.h

#include <stdio.h>

unsigned char *base64_encode(const unsigned char *data, size_t len);
unsigned char *base64_decode(const unsigned char *b64_data, size_t len);

my_base64.c

#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include "b64module.h"

static PyObject *b64encode(PyObject *self, PyObject *data) {
    if (!PyBytes_CheckExact(data)) {
        PyErr_Format(PyExc_ValueError, 
            "data must be a bytes, not '%s'\n", Py_TYPE(data)->tp_name);
        return NULL;
    }
    char *src = PyBytes_AsString(data);
    ssize_t len = strlen(src);
    unsigned char *dst = base64_encode((unsigned char *)src, len);
    PyObject *b64_data = PyBytes_FromString((const char *)dst);
    free(dst);
    return b64_data;
}

#define is_valid_b64_character(c) \
    (((c) >= 'A' && (c) <= 'Z') || \
     ((c) >= 'a' && (c) <= 'z') || \
     ((c) >= '0' && (c) <= '9') || \
     ((c) == '+') || ((c) == '/'))

static PyObject *b64decode(PyObject *self, PyObject *b64_data) {
    if (!PyBytes_CheckExact(b64_data)) {
        PyErr_Format(PyExc_ValueError, 
            "b64_data must be a bytes, not '%s'", Py_TYPE(b64_data)->tp_name);
        return NULL;
    }
    char *src = PyBytes_AsString(b64_data);
    ssize_t len = strlen(src);
    // 检验一下 base64 字符串的合法性
    // 长度必须是 4 的整数倍
    if (len % 4 != 0) {
        PyErr_Format(PyExc_ValueError, "b64_data length must be 4n");
        return NULL;
    }
    // 字符必须是 'a' ~ 'z'、'A' ~ 'Z'、'+'、'/'、'=',并且 '=' 要在字符串的最后
    int flag = 1;
    int i;
    for (i = 0; i < len - 2; i++) {
        if (!is_valid_b64_character(src[i])) {
            flag = 0;
            break;
        }
    }
    // 单独判断最后两个字符,如果倒数第二个字符为 '=',那么最后一个必须也是 '='
    if (src[i] == '=') {
        if (src[i + 1] != '=')
            flag = 0;
    } else if (!is_valid_b64_character(src[i])) {
        flag = 0;
    } else {
        // 到这里说明倒数第二个字符不为 '='
        if (!(src[i + 1] == '=' || is_valid_b64_character(src[i + 1]))) {
            flag = 0;
        }
    }
    if (!flag) {
        PyErr_Format(PyExc_ValueError, "invalid b64_data");
        return NULL;
    }
    unsigned char *dst = base64_decode((unsigned char *)src, len);
    PyObject *data = PyBytes_FromString((const char *)dst);
    free(dst);
    return data;
}

static PyMethodDef methods[] = {
    {"b64encode", (PyCFunction) b64encode, METH_O, NULL},
    {"b64decode", (PyCFunction) b64decode, METH_O, NULL},
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "my_base64",
    "我们自己实现的 base64",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC PyInit_my_base64(void) {
 return PyModule_Create(&module);
}

以上我们就封装了一个解析 base64 字符串的模块,将其编译成扩展,然后来测试一下:

import base64
import my_base64

data = "这是一段很长的字符串,到底有多长我也不知道,总之就是非常长".encode("utf-8")
print(
    base64.b64encode(data) == my_base64.b64encode(data)
)  # True

b64_data = base64.b64encode(data)
print(my_base64.b64decode(b64_data) == data)  # True

结果没有问题,那么速度呢?我们实现的和解释器自带的,哪个速度更快呢?

在解码方面势均力敌,但编码是我们赢了。