第3章 基本概念:插槽、令牌、会话、对象

PKCS​11 的 API 看着函数不少,但核心概念其实就四个:插槽、令牌、会话、对象。搞明白它们之间的关系,后面的代码写起来就顺了。

一句话概括:应用通过插槽找到令牌,在令牌上打开会话,然后用会话操作对象。

3.1 插槽(Slot)

插槽是个逻辑概念。你可以把它理解为设备管理器里的一个「槽位」。物理上对应一个 USB 钥匙、一张 PCIe 卡,或者一个 SIM 卡大小的 SE 芯片。

我刚开始接触 PKCS​11 时,以为插槽就是物理接口。后来做项目才发现,一个硬件设备可以暴露多个逻辑插槽——比如一个 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 可选,不加就是只读会话

你想想看,如果只是做签名验证,只读会话就够了。需要导入密钥时才开读写会话。这是个好习惯——最小权限原则嘛。

会话的生命周期

  1. 打开:C_OpenSession 返回会话句柄。
  2. 登录:C_Login 绑定用户身份到会话。
  3. 操作:查找对象、加解密、签名验签。
  4. 关闭:C_CloseSession 释放资源。

避坑:我曾经在并发测试中一个进程开了 200 个会话没关,结果令牌直接拒绝服务了。大多数令牌对并发会话数有限制(一般是 10~50 个),用完了记得关。

3.4 对象(Object)

对象是令牌上存储的「东西」。密钥、证书、数据,在 PKCS​11 里都是对象。每个对象有属性和句柄。

对象的分类

对象类 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 四者关系

这四个概念的关系,我用一张图来总结。你跟着箭头看一遍就清楚了。

PKCS​11 核心概念关系 插槽(Slot) 物理或逻辑接口,可插拔令牌 令牌() 安全存储介质,需用户登录验证 会话(Session) 操作上下文,读写或只读模式 对象(Object) 密钥 / 证书 / 数据,通过属性定义与句柄访问 每个会话绑定一个令牌,通过句柄操作令牌上的对象 插槽枚举 → 令牌登录 → 会话打开 → 对象操作,按序执行

图中从插槽到对象的箭头,代表了典型的调用链路。你在代码里也是按这个顺序写:

  1. C_GetSlotList 拿到可用插槽。
  2. 选一个插槽,C_OpenSession 打开会话。
  3. C_Login 登录到令牌。
  4. C_FindObjects / C_GetAttributeValue 操作对象。
  5. 用完调 C_CloseSession 关闭会话。

核心原则:插槽是入口,令牌是载体,会话是通道,对象是数据。四者缺一不可,顺序不能乱。

实际项目中,我见过不少人把插槽 ID 和会话句柄搞混。记住:插槽 ID 是 CK_SLOT_ID 类型,会话句柄是 CK_SESSION_HANDLE 类型,两个完全不同的东西。编译器不会报错,但运行时必然 CKR_SLOT_ID_INVALID 或者 CKR_SESSION_HANDLE_INVALID。

我的习惯:在代码里把插槽 ID 命名为 slotID,会话句柄命名为 hSession,一眼就能区分。团队规范里也建议这么做。