第3章 基本概念:插槽、令牌、会话、对象
PKCS11 的 API 看着函数不少,但核心概念其实就四个:插槽、令牌、会话、对象。搞明白它们之间的关系,后面的代码写起来就顺了。
一句话概括:应用通过插槽找到令牌,在令牌上打开会话,然后用会话操作对象。
3.1 插槽(Slot)
插槽是个逻辑概念。你可以把它理解为设备管理器里的一个「槽位」。物理上对应一个 USB 钥匙、一张 PCIe 卡,或者一个 SIM 卡大小的 SE 芯片。
我刚开始接触 PKCS11 时,以为插槽就是物理接口。后来做项目才发现,一个硬件设备可以暴露多个逻辑插槽——比如一个 HSM 内部划分成多个独立分区,每个分区就是一个插槽。
插槽的状态
每个插槽有几种状态标志,调用 C_GetSlotInfo 就能查。常用的就这么几个:
| 标志 | 含义 | 说明 |
|---|---|---|
CKF__PRESENT |
令牌存在 | 插槽里有令牌插入,可以操作 |
CKF_REMOVABLE |
可移除 | 令牌可以拔出来,比如 USB Key |
CKF_HW_SLOT |
硬件插槽 | 对应真实物理设备,不是纯软件模拟 |
注意:插槽枚举是每次调 C_GetSlotList 时动态获取的。不要在程序启动时缓存一次就一直用——我遇到过设备热插拔后插槽索引发生变化,程序直接崩溃的情况。
实际调用很简单:
CK_SLOT_ID slotList[10];
CK_ULONG slotCount;
rv = C_GetSlotList(CK_TRUE, slotList, &slotCount);
// slotCount 返回实际插槽数量
// slotList 里就是可用的插槽 ID
这里第一个参数 CK_TRUE 表示只返回有令牌的插槽。想查所有插槽就传 CK_FALSE。我个人习惯先用 CK_TRUE 扫一遍,没有可用插槽再给用户提示「请插入密钥设备」。
3.2 令牌()
令牌就是插在插槽里的那个「东西」。它负责存储密钥、证书,并执行密码运算。常见的令牌包括 USB Key、智能卡、HSM 的逻辑分区。
每个令牌有自己的厂商信息、序列号、存储容量。用 C_GetTokenInfo 可以读到:
CK__INFO tokenInfo;
rv = C_GetTokenInfo(slotID, &tokenInfo);
// tokenInfo.label - 令牌标签
// tokenInfo.manufacturerID - 厂商名
// tokenInfo.serialNumber - 序列号
// tokenInfo.flags - 各种状态标志
令牌的用户体系
令牌上有两种用户角色:
- 安全官(SO)——负责初始化令牌、设置管理员 PIN,一般不参与日常密码运算。
- 普通用户(User)——日常操作密钥、签名、加密用的角色。
登录时调用 C_Login:
rv = C_Login(session, CKU_USER, (CK_UTF8CHAR_PTR)"123456", 6);
// CKU_USER - 普通用户登录
// CKU_SO - 安全官登录
// CKU_CONTEXT_SPECIFIC - 特定上下文(比如解密时需要再次验证)
我记得有一次项目里,同事没检查令牌是否已经登录就直接调 C_SignInit,结果一直返回 CKR_USER_NOT_LOGGED_IN。排查了半天,其实就是少调了一步 C_Login。
经验之谈:登录失败后记得调 C_CloseSession 重新打开会话再试,有些令牌在登录失败后会锁住会话。
3.3 会话(Session)
会话是应用与令牌之间的「操作通道」。所有密码运算、对象查找、对象读写,都得在会话里做。
会话的类型
| 类型 | 标识 | 说明 |
|---|---|---|
| 只读会话 | 默认(不指定 CKF_RW_SESSION) |
只能读对象、做密码运算,不能修改令牌上的对象 |
| 读写会话 | 指定 CKF_RW_SESSION |
可以创建、修改、删除令牌上的对象 |
打开会话用 C_OpenSession:
CK_SESSION_HANDLE session;
rv = C_OpenSession(slotID, CKF_SERIAL_SESSION | CKF_RW_SESSION,
NULL, NULL, &session);
// CKF_SERIAL_SESSION 是必填标志
// CKF_RW_SESSION 可选,不加就是只读会话
你想想看,如果只是做签名验证,只读会话就够了。需要导入密钥时才开读写会话。这是个好习惯——最小权限原则嘛。
会话的生命周期
- 打开:
C_OpenSession返回会话句柄。 - 登录:
C_Login绑定用户身份到会话。 - 操作:查找对象、加解密、签名验签。
- 关闭:
C_CloseSession释放资源。
避坑:我曾经在并发测试中一个进程开了 200 个会话没关,结果令牌直接拒绝服务了。大多数令牌对并发会话数有限制(一般是 10~50 个),用完了记得关。
3.4 对象(Object)
对象是令牌上存储的「东西」。密钥、证书、数据,在 PKCS11 里都是对象。每个对象有属性和句柄。
对象的分类
| 对象类 | CKA_CLASS 值 |
常见例子 |
|---|---|---|
| 密钥对象 | CKO_SECRET_KEY |
AES 密钥、DES 密钥 |
| 公钥对象 | CKO_PUBLIC_KEY |
RSA 公钥、ECC 公钥 |
| 私钥对象 | CKO_PRIVATE_KEY |
RSA 私钥、ECC 私钥 |
| 证书对象 | CKO_CERTIFICATE |
X.509 证书 |
| 数据对象 | CKO_DATA |
应用自定义数据 |
| 域对象 | CKO_DOMAIN_PARAMETERS |
DSA 参数、DH 参数 |
对象的属性
每个对象由一组属性定义。比如一个 AES 密钥:
CK_OBJECT_CLASS class = CKO_SECRET_KEY;
CK_KEY_TYPE keyType = CKK_AES;
CK_ULONG valueLen = 32; // 256 位
CK_BYTE value[32] = { ... };
CK_BBOOL = CK_TRUE; // 存储在令牌上
CK_BBOOL private = CK_TRUE; // 需要登录才能访问
CK_ATTRIBUTE template[] = {
{CKA_CLASS, &class, sizeof(class)},
{CKA_KEY_TYPE, &keyType, sizeof(keyType)},
{CKA_VALUE_LEN, &valueLen, sizeof(valueLen)},
{CKA_VALUE, value, sizeof(value)},
{CKA_, &, sizeof()},
{CKA_PRIVATE, &private, sizeof(private)}
};
rv = C_CreateObject(session, template, 6, &hObject);
搜索对象时也是用属性模板匹配:
CK_OBJECT_CLASS class = CKO_CERTIFICATE;
CK_ATTRIBUTE searchTemplate[] = {
{CKA_CLASS, &class, sizeof(class)}
};
rv = C_FindObjectsInit(session, searchTemplate, 1);
rv = C_FindObjects(session, objectList, 10, &count);
rv = C_FindObjectsFinal(session);
小技巧:查找对象记得成对调 Init 和 Final。我见过有人调了 Init 忘了 Final,结果后续操作全部返回 CKR_OPERATION_ACTIVE。
3.5 四者关系
这四个概念的关系,我用一张图来总结。你跟着箭头看一遍就清楚了。
图中从插槽到对象的箭头,代表了典型的调用链路。你在代码里也是按这个顺序写:
C_GetSlotList拿到可用插槽。- 选一个插槽,
C_OpenSession打开会话。 C_Login登录到令牌。C_FindObjects/C_GetAttributeValue操作对象。- 用完调
C_CloseSession关闭会话。
核心原则:插槽是入口,令牌是载体,会话是通道,对象是数据。四者缺一不可,顺序不能乱。
实际项目中,我见过不少人把插槽 ID 和会话句柄搞混。记住:插槽 ID 是 CK_SLOT_ID 类型,会话句柄是 CK_SESSION_HANDLE 类型,两个完全不同的东西。编译器不会报错,但运行时必然 CKR_SLOT_ID_INVALID 或者 CKR_SESSION_HANDLE_INVALID。
我的习惯:在代码里把插槽 ID 命名为 slotID,会话句柄命名为 hSession,一眼就能区分。团队规范里也建议这么做。