4. PKCS#11对象模型:数据对象、密钥对象、证书对象、模板与属性

这一章咱们聊聊PKCS#11的对象模型。嗯,这算是整个标准里最核心的概念之一。你想想看,HSM也好、也罢,里面存的无非就是几类东西——密钥、证书、还有你自定义的数据。PKCS#11把这些东西统称为"对象"。

我刚接触PKCS#11那会儿,最怕的就是搞不清对象类型和属性之间的关系。一个RSA公钥,到底该用CKO_PUBLIC_KEY还是CKO_CERTIFICATE?后来磕了几次才明白,它们根本就是两回事。这节课咱们就把这些概念彻底理清。

4.1 对象模型的总体结构

PKCS#11里的对象,说白了就是一个"属性袋子"。每个对象都有一个类型(CKO_*)和一堆属性(CKA_*)。你创建对象时,就是通过一个模板(CK_ATTRIBUTE数组)把这些属性塞进去。

我个人习惯把对象分成三大类:

  • 数据对象(CKO_DATA)—— 存任意字节,比如日志、配置文件
  • 密钥对象 —— 又分公钥、私钥、密钥类型三种
  • 证书对象(CKO_CERTIFICATE)—— 存X.509证书本体

除此之外还有Domain Parameters、Mechanism等冷门类型,但实战中咱们主要跟这三类打交道。

核心记忆点:PKCS#11对象 = CKO_类型 + CKA_属性集合。你操作一个对象,实际上就是在读写它的属性。

下面这张图展示了各类对象之间的关系:

PKCS#11 对象模型结构 PKCS#11 对象 CKO_DATA 数据对象 CKO_*KEY 密钥对象 CKO_CERTIFICATE 证书 CKO_PUBLIC_KEY CKO_PRIVATE_KEY CKO_SECRET_KEY 每个对象由 CKO_类型 + CKA_属性数组 唯一确定 模板(CK_ATTRIBUTE[])是创建/搜索对象的唯一方式 证书对象内部包含公钥信息,但存储结构独立于密钥对象

4.2 数据对象(CKO_DATA)

数据对象很简单,就是存一段二进制。它的核心属性只有一个:CKA_VALUE,长度不限。

我用它存过审计日志的HMAC密钥种子。当时需要把种子和证书分开保管,就用数据对象放在另一个上。你想想看,私钥不能导出,但数据对象的CKA_VALUE可以自由读取,前提是你有相应的操作权限。

属性类型说明
CKA_CLASSCK_OBJECT_CLASS固定为 CKO_DATA
CKA_CK_BBOOLTRUE=存在HSM上;FALSE=临时会话对象
CKA_PRIVATECK_BBOOLTRUE=需要登录才能访问
CKA_MODIFIABLECK_BBOOLTRUE=创建后可修改属性
CKA_LABELCK_UTF8CHAR[]给对象取个名字,方便识别
CKA_VALUECK_BYTE[]实际存储的字节数据

实战技巧:用数据对象存日志序列号或审计计数器时,记得设置CKA_MODIFIABLE = CK_FALSE。这样一旦写入就不能改了,防篡改。我之前一个项目就因为忘了设这个,被客户审计时发现计数器被重写,搞得重做了全套方案。

4.3 密钥对象(公钥、私钥、密钥)

密钥对象分三大类,每一类又有自己的子类型属性。咱们先看公共部分:

  • CKO_PUBLIC_KEY:公钥,可以导出,通常不加密。
  • CKO_PRIVATE_KEY:私钥,永远不能导出(除非支持,但一般禁掉)。
  • CKO_SECRET_KEY:对称密钥,两端共享。

记得我刚开始做审计日志加密时,想用RSA公钥直接加密每条日志。结果发现PKCS#11的公钥对象只存储模数n和指数e,真正做加密是通过C_EncryptInit传入CK_OBJECT_HANDLE,而不是自己操作公钥字节。这是个常见误区。

4.3.1 公钥对象属性

以RSA为例,创建公钥对象至少要指定:

CK_OBJECT_CLASS class = CKO_PUBLIC_KEY;
CK_KEY_TYPE keyType = CKK_RSA;
CK_BYTE modulus[] = { 0xAB, 0xCD, ... }; // 模数
CK_BYTE exponent[] = { 0x01, 0x00, 0x01 }; // 公钥指数

CK_ATTRIBUTE template[] = {
    {CKA_CLASS, &class, sizeof(class)},
    {CKA_KEY_TYPE, &keyType, sizeof(keyType)},
    {CKA_, &ckTrue, sizeof(CK_BBOOL)},
    {CKA_MODULUS, modulus, sizeof(modulus)},
    {CKA_PUBLIC_EXPONENT, exponent, sizeof(exponent)},
    {CKA_VERIFY, &ckTrue, sizeof(CK_BBOOL)}   // 允许用此公钥验签
};

我习惯在创建前先用C_FindObjectsInit搜索是否已有同指纹的公钥,避免重复存储。嗯,后面讲模板搜索时会细说。

4.3.2 私钥对象属性

私钥比公钥多几个关键属性:

  • CKA_SIGN / CKA_UNWRAP:控制私钥用途
  • CKA_PRIVATE:必须为TRUE,确保需登录才能访问
  • CKA_SENSITIVE:TRUE时私钥值永远不可导出
  • CKA_EXTRACTABLE:FALSE则完全禁止导出

避坑指南:我曾经在生产上创建了一对RSA密钥,忘记设置CKA_SENSITIVE = CK_TRUE。结果运维人员用C_GetAttributeValue把私钥的模数和私有指数全部读出来了——虽然没明文导出整个私钥,但指数泄露等于私钥暴露。从那以后,我规定所有生产私钥必须设CKA_SENSITIVE = TRUE且CKA_EXTRACTABLE = FALSE。

4.3.3 对称密钥属性

对称密钥(如AES)的属性相对简单:

属性典型值说明
CKA_VALUE_LEN16/24/32密钥字节长度(AES-128/192/256)
CKA_VALUE(密钥字节)生成后才能读取?不,通常设为敏感不可读
CKA_ENCRYPTCK_TRUE允许加密
CKA_DECRYPTCK_TRUE允许解密
CKA_WRAPCK_TRUE允许用此密钥封装其他密钥
CKA_UNWRAPCK_TRUE允许解封

注意:对称密钥的CKA_VALUE创建时由HSM内部生成,你没法通过C_GetAttributeValue读出来(如果设了敏感)。这也是保护机制。

4.4 证书对象(CKO_CERTIFICATE)

证书对象用来存储X.509证书的DER编码。它的核心属性:

  • CKA_VALUE:证书本体(DER字节)
  • CKA_CERTIFICATE_TYPE:通常为CKC_X_509
  • CKA_SUBJECT:主题DN
  • CKA_ISSUER:签发者DN
  • CKA_SERIAL_NUMBER:序列号
  • CKA_ID:标识符,用来和私钥对象关联

这里CKA_ID特别重要。你回想一下,我登录后怎么把证书和对应的私钥匹配起来?就是靠这个ID。通常做法是:生成密钥对时给私钥和公钥都设同一个CKA_ID,然后导入证书时也设同样的ID。

// 假设已经生成了RSA密钥对,且获得了 handlePrivate, handlePublic
CK_BYTE keyId[] = {0x01, 0x02, 0x03, 0x04};

// 为密钥设置CKA_ID
CK_ATTRIBUTE setKeyId[] = {
    {CKA_ID, keyId, sizeof(keyId)}
};
C_SetAttributeValue(session, handlePrivate, setKeyId, 1);
C_SetAttributeValue(session, handlePublic, setKeyId, 1);

// 导入证书时也带同样的CKA_ID
CK_ATTRIBUTE certTemplate[] = {
    {CKA_CLASS, &certClass, sizeof(certClass)},
    {CKA_CERTIFICATE_TYPE, &certType, sizeof(certType)},
    {CKA_VALUE, certDer, certDerLen},
    {CKA_SUBJECT, subjectDer, subjectDerLen},
    {CKA_ID, keyId, sizeof(keyId)}   // 关联密钥
};
C_CreateObject(session, certTemplate, 4, &hCert);

我遇到过的一个麻烦:证书对象的CKA_SUBJECT是DER编码(不是字符串),很多人误传了字符串进去,结果C_CreateObject返回CKR_ATTRIBUTE_VALUE_INVALID。你要通过ASN.1库把DN编码成DER再传。

4.5 模板(Template)与属性(Attribute)

模板就是CK_ATTRIBUTE数组,它是操作对象的核心。不管是创建、搜索、还是修改对象,都得通过模板传递属性。

几个关键操作:

  1. 创建对象:C_CreateObject(session, template, ulCount, &hObject)。模板里必须包含CKA_CLASS和类型相关的必填属性。
  2. 搜索对象:C_FindObjectsInit(session, template, ulCount)。模板里只放你想匹配的属性,比如只搜CKA_CLASS = CKO_PRIVATE_KEY。
  3. 读取属性:C_GetAttributeValue(session, hObject, template, ulCount)。先把属性模板传进去(属性类型填好,pValue=NULL),第一次调用获取长度;分配内存后再调一次获取值。
  4. 修改属性:C_SetAttributeValue(session, hObject, template, ulCount)。注意有些属性不可修改(如CKA_KEY_TYPE)。

我的经验:搜索对象时,模板属性越多,匹配越精确,但性能会下降。实际项目中,我一般只用CKA_CLASS + CKA_ID两个属性搜索,又快又准。如果ID用随机数生成(比如UUID),几乎不会碰撞。

属性有五种取值来源:

  • 默认值:PKCS#11标准为每个属性定义了默认值,比如CKA_默认为CK_FALSE(会话对象)。
  • 创建时指定:你在模板里显式设置的属性值。
  • 派生/计算值:比如RSA私钥的CKA_MODULUS是从公钥部分复制过来的。
  • 不可修改的固定值:如CKA_CLASS、CKA_KEY_TYPE。
  • 敏感/不可读值:如私钥的CKA_PRIVATE_EXPONENT,你读不到。

最后说个细节:C_GetAttributeValue如果某个属性不可读,会返回CKR_ATTRIBUTE_SENSITIVE或CKR_ATTRIBUTE_TYPE_INVALID。你不能假设所有属性都能读出来。我记得调试一次时,程序直接崩溃了——因为没检查返回值,结果把一个敏感属性的长度当成0,分配了0字节缓冲区,后面memcpy就崩了。所以一定要逐个检查返回值。

本章核心:PKCS#11的一切都是对象。对象由类型决定结构,由模板驱动操作。理解这点,你就掌握了80%的PKCS#11编程。剩下的20%是各种机制的细节,但对象模型是底座。