65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Win32 Cryptographic API 哈希数据

starIconstarIconstarIconstarIconstarIcon

5.00/5 (11投票s)

2022年8月26日

MIT

15分钟阅读

viewsIcon

11755

downloadIcon

410

本文介绍了一种使用最新的 Win32 API 和 C++ 来哈希数据的方法

引言

在本文中,我将展示如何使用 Cryptography API 的包装器类,通过便捷且可重用的方法来哈希数据,从而结合易用性和加密完整性。请注意,加密学是一个复杂的学科,在面对攻击时保证安全性最好留给专家。我的文章重点在于 CNG 的使用。我不会深入讨论密钥管理/密钥存储,因为我在这方面没有实际经验,而错误的建议比没有建议更糟糕。

实现这个包装器类的原因是我需要创建一个用于校验和的哈希值,它在统计学上保证是唯一的。这涉及几个步骤,但大多数都是样板代码,可以隐藏起来。

在提供几个用于将数据流式传输到哈希对象的辅助函数时,我也深入研究了 C++ 的概念,这使得实现非常具体地定义哪些类型可以与各种模板函数一起使用变得轻而易举。这些实现也将在下面进行解释。测试应用程序会进行各种哈希来演示本文的要点。

背景

在另一篇文章中,我需要创建带有唯一名称的互斥体(mutex)在 Global 命名空间中。这很容易通过 GUID 来完成。然而,用例也要求考虑可执行文件的路径。

使用路径本身可能会导致互斥体的名称过长,而且“\”字符也不能使用。最佳的解决方案是创建 GUID 和可执行文件路径的哈希,该哈希在统计学上保证是唯一的。

这开始是“我的文章快完成了,我只需要实现一个哈希函数就结束了”,然后变成了“Win32 对哈希数据的加密支持真的很有趣,我也应该写一篇文章”,然后又进入了“我要做一些只适用于非常特定类型子集的模板”的陷阱,最后以“C++20 概念太棒了”结束。

使用 CNG API

Cryptographic Next Generation (CNG) API 使用起来非常方便。它实现于 BCrypt.dll 中。您可以使用如下所示的图表进行操作:

客户端通过 BCrypt.dll 打开特定算法的句柄,例如 BCRYPT_SHA256_ALGORITHM。如果客户端未指定要使用的提供程序,BCrypt 将检查算法提供程序集合,并选择第一个支持所请求算法的提供程序。

哈希算法需要两样东西:一个用于存储哈希对象本身(特定算法实例的状态和配置)的缓冲区,以及一个用于哈希输出的缓冲区。两个缓冲区的长度都可以从 BCrypt 加载的算法中检索。

在配置了这些之后,客户端就可以提供输入数据,这些数据将根据配置的算法进行哈希。数据不必一次性全部提供,可以反复添加更多数据。这将在后面很有用。最后,客户端会发出信号表示哈希可以完成。就是这样!哈希值已经存储在先前分配的缓冲区中,随时可用。

实现包装器类

包装器类的代码实现在 w32_CHashObject 中。

class w32_CHashObject
{
private:
    BCRYPT_ALG_HANDLE       m_hAlg = NULL;                      //handle to the 
                                                                //algorithm object
    BCRYPT_HASH_HANDLE      m_hHash = NULL;                     //handle to the 
                                                                //hash object
    NTSTATUS                m_status = STATUS_UNSUCCESSFUL;     //state of the 
                                                                //wrapper object             
    DWORD                   m_sizeofHashObject = 0;             //Size of the 
                                                                //hash object
    DWORD                   m_sizeofHash = 0;                   //size of the hash itself
    PBYTE                   m_pbHashObject = NULL;              //buffer for the 
                                                                //internal hash object
    PBYTE                   m_pbHash = NULL;                    //buffer for the 
                                                                //hash itself

public:
    w32_CHashObject(
        LPCWSTR algorithmProvider = NULL,
        LPCWSTR algorithmID = BCRYPT_SHA256_ALGORITHM,
        PUCHAR secret = NULL,
        ULONG sizeofSecret = 0);
    ~w32_CHashObject();

    LSTATUS GetStatus(void);                                //Get the status of the 
                                                            //wrapper object
    LSTATUS AddData(PBYTE data, ULONG numBytes);            //Hash more data
    LSTATUS Finish(void);                                   //Finalize the 
                                                            //hash operation

    DWORD GetHashSize(void);                                //Get the size of the hash
    LSTATUS GetHash(PBYTE buffer, ULONG bufferSize);        //Get the hash data
};

BCrypt 哈希算法的使用需要两个句柄(算法和哈希对象)和两个缓冲区(哈希对象和哈希),每个都有一个缓冲区和大小变量。包装器的内部状态是各种 API 调用返回的 NTSTATUS 值。

我决定一旦其中一个 API 调用返回错误,包装器就进入错误状态。一旦出现问题,包装器就会变得无用。原因是,我们永远无法 100% 确定我们完全理解该错误的影响。如果我们开始尝试解决它,我们无法确定对哈希值的影响。使用一个错误/不可靠的值是非常糟糕的主意。此状态始终可以通过 GetStatus() 进行检查。

其余三个函数仅用于向哈希对象添加输入数据、完成哈希操作以及检索哈希的副本。

构造和析构

这部分需要大部分代码。

w32_CHashObject::w32_CHashObject(
    LPCWSTR algorithmProvider,
    LPCWSTR algorithmID,
    PUCHAR secret,
    ULONG sizeofSecret) {
    DWORD cbData = 0;

    //open an algorithm handle and load the algorithm provider
    if (!NT_SUCCESS(m_status = BCryptOpenAlgorithmProvider(
        &m_hAlg, algorithmID, algorithmProvider, 0))) {
        return;
    }

    //calculate the size of the buffer to hold the hash object
    if (!NT_SUCCESS(m_status = BCryptGetProperty(
        m_hAlg, BCRYPT_OBJECT_LENGTH, 
        (PBYTE)&m_sizeofHashObject,sizeof(DWORD), &cbData, 0))) {
        return;
    }

    //allocate the hash object on the heap
    m_pbHashObject = (PBYTE)HeapAlloc(GetProcessHeap(), 0, m_sizeofHashObject);
    if (NULL == m_pbHashObject) {
        m_status = STATUS_NO_MEMORY;
        return;
    }

    //calculate the length of the hash
    if (!NT_SUCCESS(m_status = BCryptGetProperty(
        m_hAlg, BCRYPT_HASH_LENGTH, 
        (PBYTE)&m_sizeofHash, sizeof(DWORD), &cbData, 0))) {
        return;
    }

    //allocate the hash buffer on the heap
    m_pbHash = (PBYTE)HeapAlloc(GetProcessHeap(), 0, m_sizeofHash);
    if (NULL == m_pbHash) {
        m_status = STATUS_NO_MEMORY;
        return;
    }

    //Validate the arguments for the secret if they were supplied
    if ((secret != NULL && sizeofSecret == 0) ||
        (secret == NULL && sizeofSecret != 0)) {
        m_status = STATUS_INVALID_PARAMETER;
        return;
    }

    //Initialize the hash object
    if (!NT_SUCCESS(m_status = BCryptCreateHash(
        m_hAlg, &m_hHash, m_pbHashObject, m_sizeofHashObject, secret, sizeofSecret, 0)))
        return;
}

//Release the various resources
w32_CHashObject::~w32_CHashObject() {
    if(m_hAlg != NULL)
        BCryptCloseAlgorithmProvider(m_hAlg, 0);
    if(m_hHash != NULL) 
        BCryptDestroyHash(m_hHash);
    if (m_pbHashObject)
        HeapFree(GetProcessHeap(), 0, m_pbHashObject);
    if (m_pbHash)
        HeapFree(GetProcessHeap(), 0, m_pbHash);
}

第一步是请求 BCrypt 通过 BCryptOpenAlgorithmProvider 加载请求的算法,然后使用 BCryptGetProperty 函数检索所需的缓冲区大小。从声明中可以看出,我们允许提供程序名称为空,算法也可以为空。任何提供程序都可以,除非您有特定原因要求特定提供程序。如果使用哪个哈希算法也无关紧要,我将 BCRYPT_SHA256_ALGORITHM 用作一个足够安全的默认值。

通过调用 BCryptCreateHash 来启动哈希操作。起初,我们也可以将密钥留空似乎很奇怪。然而,有很多情况我们只需要哈希作为一种花哨的校验和,用于数据完整性而不是安全/签名目的。在这种情况下,我们不仅不需要安全密钥,而且不使用密钥还可以让第三方验证校验和。

析构只是释放构造函数中创建的对象。

计算哈希值

向哈希添加数据非常简单,因为对于哈希对象来说,它只是一个具有给定大小的输入缓冲区。

LSTATUS w32_CHashObject::AddData(PBYTE data, ULONG dataSize) {
    if (!NT_SUCCESS(m_status))
        return m_status;

    if (dataSize <= 0)
        return m_status;

    m_status = BCryptHashData(m_hHash, data, dataSize, 0);
    return m_status;
}

LSTATUS w32_CHashObject::Finish(void) {
    if (!NT_SUCCESS(m_status))
        return m_status;

    m_status = BCryptFinishHash(
        m_hHash, m_pbHash, m_sizeofHash, 0);

    return m_status;
}

正如您所见,我们将状态用作哨兵。一旦它变坏,它就会一直坏下去。当所有数据都哈希完毕后,我们必须完成哈希。完成之后,就无法再添加数据了。

计算哈希值

从技术上讲,我们可以将内部哈希缓冲区暴露给外部,以便获取最终哈希值,但我更喜欢将所有由 BCrypt.dll 处理的内容保留在内部。相反,我们只使用两个函数来获取数据。哈希的大小本身就很小,所以复制数据的额外开销微不足道。

DWORD w32_CHashObject::GetHashSize(void) {
    return m_sizeofHash;
}

LSTATUS w32_CHashObject::GetHash(PBYTE buffer, ULONG bufferSize) {
    if (!NT_SUCCESS(m_status))
        return m_status;

    if (bufferSize >= m_sizeofHash) {
        memcpy_s(buffer, bufferSize, m_pbHash, m_sizeofHash);
        return NO_ERROR;
    }
    else {
        return STATUS_INVALID_PARAMETER;
    }
}

模板辅助函数

向哈希函数添加输入是通过一个具有给定长度的 BYTE 缓冲区完成的。但在大多数情况下,这并不方便。根据您的用例,您可能需要添加一个 double、一个 bool、一个 DWORD……手动进行所有这些转换在您的代码中会很繁琐且容易出错。幸运的是,C++ 有与模板函数一起工作的可能性。我假设阅读本文的大多数人都对模板编程有所了解。

对于模板编程新手来说,这是一种对给定类型变量进行操作的函数编程方法,但您只在编译时提供实际类型。编译器将采用通用的模板函数,并为每个使用该函数的类数据类型创建一个版本。

在我们的例子中,模板函数如下所示:

template<class T>
LSTATUS AddDataToHash(w32_CHashObject& hash, const T& data) {
    return hash.AddData((PBYTE) & data, sizeof(T));
}

在还不关心实际类型的情况下,我们使用 data 变量的内存地址作为缓冲区指针,以及 data 变量的大小作为字节数来调用 AddData 方法。此函数适用于任何离散类型,因为编译器在编译时拥有所有必要信息。如果代码执行此操作:

AddDataToHash(hashObject, double(1.23));

那么编译器将创建一个看起来像这样的函数版本(伪代码,非实际编译器代码)。

STATUS AddDataToHash<double>(w32_CHashObject& hash, const double& data) {
    return hash.AddData((PBYTE) & double, sizeof(double));
}

无论您使用多少种数据类型,编译器都会按需创建它们,而您只需要提供一个实现。也就是说,C++ 也允许我们在某些类数据类型需要以特定方式处理的情况下定义一些特殊化。

我需要的两个用于我自己的目的的特化涉及 stringwstring 对象。显然,获取 string 对象的指针并使用 string 对象的大小是没有意义的。鉴于 string 对象在内部使用动态缓冲区,string 对象的内存快照将没有意义。我们希望我们的模板函数作用于 string 本身的数据。

template<>
LSTATUS AddDataToHash<string>(w32_CHashObject& hash, const string& data);

template<>
LSTATUS AddDataToHash<string>(w32_CHashObject& hash, const string& data) {
    if (data.size() <= 0)
        return hash.GetStatus();

    PUCHAR input = (PUCHAR)(data.c_str());
    ULONG numBytes = (ULONG)(data.length() * sizeof(data[0]));
    return hash.AddData(input, numBytes);
}

string 类提供了一个方便的方法来获取指向其表示数据的缓冲区指针,我们可以使用该缓冲区将数据馈送到我们的哈希对象中。

还有一个案例值得特别关注。在许多情况下,我们已经有一个可以流式传输到哈希对象的缓冲区。虽然可以直接调用 AddData 方法,但为了保持一致性,我也提供了一个模板函数。

template<class T>
LSTATUS AddDataToHash(w32_CHashObject& hash, T* data, ULONG numBytes) {
    return hash.AddData((PBYTE) data, numBytes);
}

每次使用缓冲区都应附带缓冲区大小。当提供缓冲区指针时,应仅使用第二个。

使用 CHashObject 类

为了演示,让我们从哈希 wstring L"Test123" 开始。我们可以通过几种方式做到这一点:

    {
        cout << "Hashing Test123 as wstring." << endl;
        w32_CHashObject hashObject;
        AddDataToHash(hashObject, wstring(L"Test123"));
        hashObject.Finish();
        PrintHash(hashObject);
    }
    {
        cout << "Hashing Test123 as individual unicode characters" << endl;
        w32_CHashObject hashObject;
        AddDataToHash(hashObject, L'T');
        AddDataToHash(hashObject, L'e');
        AddDataToHash(hashObject, L's');
        AddDataToHash(hashObject, L't');
        AddDataToHash(hashObject, L'1');
        AddDataToHash(hashObject, L'2');
        AddDataToHash(hashObject, L'3');
        hashObject.Finish();
        PrintHash(hashObject);
    }

在这两种情况下,结果都是 5952584f93d2b9ec353dadfff6e2796671f4c62bafc8cf0a83f7ff5a0e7c9e4

我们还可以一个接一个地哈希一堆不同的数据类型。

    {
        cout << "Hashing double (1.23), int (42), bool(true)" << endl;
        w32_CHashObject hashObject;
        AddDataToHash(hashObject, double(1.23));
        AddDataToHash(hashObject, int(42));
        AddDataToHash(hashObject, bool(true));
        hashObject.Finish();
        PrintHash(hashObject);
    }

这种数据组合产生了哈希值 8de7143fc6ddbf2cecc904c88119f55d12b4293cda5ef7afaddcff16944efd

一些注意事项

哈希数据时有几个注意事项需要注意。

0 终止符

之前我们哈希了文本 L"Test123",将其作为 wstring 和作为单个字符。那么如果我们将其作为 C 风格字符串进行哈希会怎样?

    {
        cout << "Hashing Test123 as wchar_t*" << endl;
        w32_CHashObject hashObject;
        wchar_t arr[] = L"Test123";
        int arrbytes = sizeof(arr);
        AddDataToHash(hashObject, arr, arrbytes);
        hashObject.Finish();
        PrintHash(hashObject);
    }

哈希值不是 8de7143fc6ddbf2cecc904c88119f55d12b4293cda5ef7afaddcff16944efd ,而是 4359ed1fea1128f56778cb2a68fe64eb39fc79b90b5a34ac7edc8de3d93fe25

经过检查,我们发现 sizeof(arr) == 16。原因是 C 风格字符串的末尾有一个 0 终止符,该终止符包含在缓冲区本身中,因此哈希的字节数比前面的示例多 2 个。如果我们想使其等效,我们必须确保只哈希数据而不哈希终止符。

    {
        cout << "Hashing Test123 as wchar_t* including 0 termination" << endl;
        w32_CHashObject hashObject;
        wchar_t arr[] = L"Test123";
        int arrbytes = sizeof(arr) - sizeof(whcar_t);
        AddDataToHash(hashObject, arr, arrbytes);
        hashObject.Finish();
        PrintHash(hashObject);
    }

这会生成哈希值 8de7143fc6ddbf2cecc904c88119f55d12b4293cda5ef7afaddcff16944efd。因此,在哈希数据时请记住:如果您依赖于结果的可重复性,您必须确切地知道您正在哈希什么以及如何提供输入。例如,是否包含 0 终止符等可能不会立即想到,但如果哈希的具体结果很重要,它们将至关重要。

结构填充

让我们以前面的例子为例,我们哈希了一个 double、一个 int 和一个 bool,将相同的变量放入一个 struct 中并重复这个练习。然后我们得到这种情况:

struct TestStruct1 {
    double d;
    int i;
    bool b;
};
    {
        cout << "Hashing TestStruct1 d = 1.23, i = 42, b = true" << endl;
        w32_CHashObject hashObject;
        TestStruct1 ts1;
        ts1.d = 1.23;
        ts1.i = 42;
        ts1.b = true;
        AddDataToHash(hashObject, ts1);
        hashObject.Finish();
        PrintHash(hashObject);
    }

乍一看,您可能会认为这将是上一个哈希的重复。相反,它产生 17b345a6bc1f28b9b3a3f57d6a6bb934a1704ebe6b95128cdd339ef6a92316.

原因是哈希的数据更多。如果我们查看各个成员的大小(8 + 4 + 1),您会期望 TestStruct1 的大小为 13 字节,而实际上 sizeof(ts1) 等于 16。有 3 个字节的填充。原因是编译器希望将每个变量对齐到其大小的倍数的内存边界。CPU 访问,例如,一个放在地址是 4 的倍数上的 4 字节整数要快得多。双精度数放在 8 字节边界上。那么如果我们重新排序成员会怎样?

struct TestStruct2 {
    bool b;
    double d;
    int i;
};

这个结构的大小是 24。b 和 d 之间有 7 个字节的填充。所以您可能期望 1 + 7 + 8 + 4 = 20。但是如果我们创建这些结构的一个数组,那么 arr[1].b 将位于偏移量 20 处,而 arr[1].d 将位于偏移量 28 处。28 对于双精度数来说不是对齐的内存地址,因此性能会急剧下降。相反,编译器会在结构末尾添加 4 个字节,以便数组中的每个成员都对齐。

这些填充字节是否被归零取决于编译器,因此您甚至不能假设具有相同成员值的 struct 会哈希到相同的值,除非

  1. 您知道编译器明确地归零结构,或者
  2. 您自己归零它们。

此外,可以通过编译器选项或pragma指示编译器更改对齐和打包行为,以消除或减少填充,但会以性能为代价,甚至可能在某些 CPU 架构上导致硬件异常。所以这也是需要考虑的事情。除非明确定义了填充行为,否则您不能假定它。

端序

即使考虑了数据格式、终止符和隐藏的填充,仍然有一个问题可能会让您绊倒:端序。如果您不熟悉端序,可以在此处找到很好的解释。简而言之,大于 1 字节的变量可以以多种不同的方式放置在物理内存中。

最常见的两种方式是小端序(最低有效字节在前)或大端序(最高有效字节在前)。还有其他更奇特的变体,但这两种是主要的。通常,您只需要在以下情况之外担心它们:

  1. 您的数据由其他处理器访问,例如 PCI 板上的芯片,或者
  2. 您的数据以字节为单位使用。

如果在 Intel CPU 上执行 TestStruct1 的哈希,那么即使我们考虑了填充初始化或对齐,在具有旧 Motorola CPU 的 Apple 计算机上,相同的操作也可能产生不同的结果。

如果您在给定的平台上工作并且不离开该平台,这通常不是您需要担心的问题,但如果您的用例涉及其他 CPU 体系结构,您可能也需要在代码中考虑这些差异。

更好的模板实现

我们已经看到,将结构或类作为输入可能产生不可预测的结果,这些结果取决于编译器选项。我们还可以预测将指针传递给 AddHashData 函数将不会按预期工作。实际上,哈希的是指针本身的值,这是没有意义的。我们可以为指针类型创建模板特化或重载,但这同样没有意义,因为

  1. 它仍然无法解决填充问题,并且
  2. 它对事物的数组没有帮助。

所以归根结底,模板函数有两个主要要求:

  1. AddHashData 应仅接受算术参数(intfloatbooldouble 等),这些参数具有固定的大小并且可以从内存中复制,或者
  2. 它们派生自具有接口的基类,该接口可以提供具有已知且可预测布局的缓冲区。

过去,这些限制可以通过模板元编程和 SNIFAE 来实现,这些实现会很快变得非常复杂,并且如果您尝试使用错误的类型,会产生可怕的编译器错误。但在 C++20 中,可以通过一种称为概念的新语言特性来实现这一点,这使得生成的模板更容易理解,并产生非常清晰的编译器错误。

算术参数支持

这是最容易实现的选项。

template<typename T>
concept HashableType = is_arithmetic<T>::value && not is_pointer<T>::value;

template<HashableType T>
LSTATUS AddDataToHash(w32_CHashObject& hash, const T& data) {
    return hash.AddData((PBYTE)&data, sizeof(T));
}

template<HashableType T>
LSTATUS AddDataToHash(w32_CHashObject& hash, T* data, ULONG numBytes) {
    return hash.AddData((PBYTE)data, numBytes);
}

我们实现一个概念“HashableType”,它定义为任何算术类型且不是指针的类型。然后可以使用该概念作为模板函数中的类型标识符。模板将仅接受概念评估为 true 的类型。如果我们尝试使用一个概念评估为 false 的类型来使用该函数,我们会得到一个清晰简单的编译器错误“C7602 associated constraints are not satisfied”或“C2672 no matching overloaded function found”。

double d = 1.23;
double *p = &d;
AddDataToHash(hashObject, d);  //OK
AddDataToHash(hashObject, p);  //C7602 or C2672

此模板也不会接受 struct 或类,因此我们不会冒险出现意外行为。

支持可哈希类/结构

这需要更多工作,因为我们需要一个合理的基类。

class w32_CHashDataProviderIF {
    virtual PBYTE Buffer() = 0;
    virtual ULONG Size() = 0;
};

template<typename T>
concept HashableClass = derived_from<T, w32_CHashDataProviderIF>;

template<HashableClass T>
LSTATUS AddDataToHash(w32_CHashObject& hash, T& data) {
    return hash.AddData(data.Buffer(), data.Size());
}

template<HashableClass T>
LSTATUS AddDataToHash(w32_CHashObject& hash, T* data) {
    return hash.AddData(data->Buffer(), data->Size());
}

我们实现一个概念 HashableClass,它定义为任何派生自 w32_CHashDataProviderIF 的类型。通过概念,现在可以轻松实现以前非常复杂的事情。类现在可以实现为派生自该基类,然后 AddDataToHash 函数将调用这些方法。由类实现者决定如何将类成员数据流式传输到哈希中。

我提供了一个默认的基类,它进行了一些内存管理,并且可以用作简单派生类的基类。我不会深入探讨实现,因为它很简单无聊,但为了这个论点,我创建了一个测试类,它代表了我们前面使用过的三个结构,带有 doubleintbool 数据成员。

class TestClass3 : public w32_CHashDataProvider {
public:
    double d;
    int i;
    bool b;
    PBYTE Buffer() {
        Allocate(Size());
        PBYTE buffer = m_Buffer;    //points to the location of the double in the buffer
        *(double*)buffer = d;
        buffer += sizeof(double);   //points to the location of the int in the buffer
        *(int*)buffer = i;
        buffer += sizeof(int);      //points to the location of the bool in the buffer
        *(bool*)buffer = b;
        return m_Buffer;
    }
    ULONG Size() {
        return sizeof(double) + sizeof(int) + sizeof(bool);
    }
};

如果我们手动管理缓冲区并确定数据写入缓冲区的哪个位置,使用一些指针算术来处理偏移量,这应该不足为奇。这样,我们就不会遇到填充或对齐等问题。

如果我们现在运行这段代码:

    {
        cout << "Hashing TestClass3 d = 1.23, i = 42, b = true" << endl;
        w32_CHashObject hashObject;
        TestClass3 tc3;
        tc3.d = 1.23;
        tc3.i = 42;
        tc3.b = true;
        AddDataToHash(hashObject, tc3);
        hashObject.Finish();
        PrintHash(hashObject);
    }

我们得到的哈希输出是 8de7143fc6ddbf2cecc904c88119f55d12b4293cda5ef7afaddcff16944efd ,这与单独哈希各个数据值时得到的结果相同。

关注点

就是这样!现在我们有了一种可靠且可预测的方式来使用模板和 Win32 API 来哈希数据。请注意,我的项目已启用 C++20 对概念的支持。它在没有该支持的情况下也可以编译,但您将无法使用那些漂亮的模板函数。我曾简要考虑过包含我原始的模板函数,但这将允许出现可能发生意外行为的用例,而我不想那样做。

测试应用程序和源代码都包含在内。所有内容均根据 MIT 许可证授权。

历史

  • 2022 年 8 月 26 日:V1 - 本文的第一个版本
© . All rights reserved.